Skip to content
16 changes: 16 additions & 0 deletions .ameba.yml
Original file line number Diff line number Diff line change
Expand Up @@ -63,3 +63,19 @@ Style/MultilineStringLiteral:

Metrics/CyclomaticComplexity:
Enabled: false

Lint/WhitespaceAroundMacroExpression:
Enabled: false

Lint/SpecEqWithBoolOrNilLiteral:
Excluded:
- "spec/**"

Style/VerboseNilType:
Enabled: false

Lint/UnusedRescueVariable:
Enabled: false

Style/MultilineCurlyBlock:
Enabled: false
5 changes: 4 additions & 1 deletion shard.override.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,7 @@ dependencies:
retriable:
github: Sija/retriable.cr
pg-orm:
github: spider-gazelle/pg-orm
github: spider-gazelle/pg-orm
sanitize:
github: straight-shoota/sanitize
branch: master
1 change: 1 addition & 0 deletions shard.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ dependencies:
# Data validation library
active-model:
github: spider-gazelle/active-model
version: ~> 4.4

# Data validation library
CrystalEmail:
Expand Down
10 changes: 10 additions & 0 deletions spec/asset_category_spec.cr
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,16 @@ module PlaceOS::Model
describe AssetCategory do
test_round_trip(AssetCategory)

it "preserves a JSON string stored in the description field" do
json_description = %({"resource_type":"locker_banks","created_at":1765438626035})
asset_category = Generator.asset_category
asset_category.description = json_description
asset_category.save!

reloaded = AssetCategory.find!(asset_category.id)
reloaded.description.should eq json_description
end

it "saves an Asset" do
asset_category = Generator.asset_category.save!

Expand Down
24 changes: 24 additions & 0 deletions spec/booking_spec.cr
Original file line number Diff line number Diff line change
Expand Up @@ -742,4 +742,28 @@ module PlaceOS::Model
deleted_booking.should_not be_nil
deleted_booking.not_nil!.deleted.should be_true
end

it "sanitizes extension_data string values before saving", tags: "extension_data_sanitization" do
tenant_id = Generator.tenant.id
user_email = "test@place.tech"

booking = Booking.new(
booking_type: "desk",
asset_ids: ["desk-1"],
booking_start: 1.hour.from_now.to_unix,
booking_end: 2.hours.from_now.to_unix,
user_email: PlaceOS::Model::Email.new(user_email),
user_name: "Test User",
booked_by_email: PlaceOS::Model::Email.new(user_email),
booked_by_name: "Test User",
tenant_id: tenant_id,
booked_by_id: "user-1234",
history: [] of Booking::History
)
booking.change_extension_data(JSON::Any.new({"note" => JSON::Any.new("<script>alert('xss')</script>hello")}))
booking.save!

saved = Booking.find!(booking.id.not_nil!)
saved.extension_data["note"].as_s.should eq "hello"
end
end
151 changes: 151 additions & 0 deletions spec/utilities/sanitization_spec.cr
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
require "spec"
require "../../src/placeos-models/utilities/sanitization"

module PlaceOS::Model
describe Sanitization, tags: "sanitization" do
describe ".sanitize_strings(JSON::Any)" do
it "strips HTML tags from string values" do
json = JSON.parse(%({ "name": "<script>alert('xss')</script>Hello" }))
result = Sanitization.sanitize_strings(json)
result["name"].as_s.should eq "Hello"
end

it "recursively sanitizes nested hashes" do
json = JSON.parse(%({
"level1": {
"level2": "<b>bold</b> text"
}
}))
result = Sanitization.sanitize_strings(json)
result["level1"]["level2"].as_s.should eq "bold text"
end

it "recursively sanitizes arrays" do
json = JSON.parse(%({
"items": ["<em>italic</em>", "<div>block</div>"]
}))
result = Sanitization.sanitize_strings(json)
result["items"][0].as_s.should eq "italic"
result["items"][1].as_s.should eq "block"
end

it "preserves non-string values" do
json = JSON.parse(%({
"count": 42,
"active": true,
"rate": 3.14,
"nothing": null
}))
result = Sanitization.sanitize_strings(json)
result["count"].as_i.should eq 42
result["active"].as_bool.should be_true
result["rate"].as_f.should eq 3.14
result["nothing"].raw.should be_nil
end

it "handles deeply nested mixed structures" do
json = JSON.parse(%({
"data": {
"users": [
{ "name": "<img src=x onerror=alert(1)>John" },
{ "name": "Jane" }
],
"count": 2
}
}))
result = Sanitization.sanitize_strings(json)
result["data"]["users"][0]["name"].as_s.should eq "John"
result["data"]["users"][1]["name"].as_s.should eq "Jane"
result["data"]["count"].as_i.should eq 2
end

it "returns clean strings unchanged" do
json = JSON.parse(%({ "name": "clean text" }))
result = Sanitization.sanitize_strings(json)
result["name"].as_s.should eq "clean text"
end

it "handles empty objects and arrays" do
json = JSON.parse(%({ "empty_obj": {}, "empty_arr": [] }))
result = Sanitization.sanitize_strings(json)
result["empty_obj"].as_h.should be_empty
result["empty_arr"].as_a.should be_empty
end

it "preserves inline tags when using the :inline policy" do
json = JSON.parse(%({ "body": "<b>bold</b> and <em>italic</em>" }))
result = Sanitization.sanitize_strings(json, :inline)
result["body"].as_s.should eq "<b>bold</b> and <em>italic</em>"
end

it "still strips non-inline tags when using the :inline policy" do
json = JSON.parse(%({ "body": "<p><script>xss</script>safe</p>" }))
result = Sanitization.sanitize_strings(json, :inline)
result["body"].as_s.should eq "safe"
end

it "recursively applies the policy to nested structures" do
json = JSON.parse(%({
"items": ["<b>bold</b>", "<p>paragraph</p>"]
}))
result = Sanitization.sanitize_strings(json, :inline)
result["items"][0].as_s.should eq "<b>bold</b>"
result["items"][1].as_s.should eq "paragraph"
end
end

describe ".sanitize_strings(Array(String))" do
it "strips HTML tags from all strings in an array" do
input = ["<b>bold</b>", "plain", "<script>xss</script>safe"]
result = Sanitization.sanitize_strings(input)
result.should eq ["bold", "plain", "safe"]
end

it "handles an empty array" do
result = Sanitization.sanitize_strings([] of String)
result.should be_empty
end

it "returns clean strings unchanged" do
input = ["hello", "world"]
result = Sanitization.sanitize_strings(input)
result.should eq ["hello", "world"]
end

it "preserves inline tags when using the :inline policy" do
input = ["<b>bold</b>", "<em>italic</em>", "<p>paragraph</p>"]
result = Sanitization.sanitize_strings(input, :inline)
result.should eq ["<b>bold</b>", "<em>italic</em>", "paragraph"]
end
end

describe ".sanitize_strings(Set(String))" do
it "strips HTML tags from all strings in a set" do
input = Set{"<b>bold</b>", "plain"}
result = Sanitization.sanitize_strings(input)
result.should contain("bold")
result.should contain("plain")
result.should_not contain("<b>bold</b>")
end

it "handles an empty set" do
result = Sanitization.sanitize_strings(Set(String).new)
result.should be_empty
end

it "returns clean strings unchanged" do
input = Set{"hello", "world"}
result = Sanitization.sanitize_strings(input)
result.should eq Set{"hello", "world"}
end

it "preserves inline tags when using the :inline policy" do
input = Set{"<b>bold</b>", "<p>paragraph</p>"}
result = Sanitization.sanitize_strings(input, :inline)
result.should contain("<b>bold</b>")
result.should contain("paragraph")
result.should_not contain("<p>paragraph</p>")
end
end
end
end
4 changes: 2 additions & 2 deletions src/placeos-models/alert.cr
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,8 @@ module PlaceOS::Model
CUSTOM
end

attribute name : String, es_subfield: "keyword"
attribute description : String = ""
attribute name : String, sanitize: :text, es_subfield: "keyword"
attribute description : String = "", sanitize: :common
attribute enabled : Bool = true, es_subfield: "keyword"
attribute any_match : Bool = false, es_subfield: "keyword"

Expand Down
4 changes: 2 additions & 2 deletions src/placeos-models/alert_dashboard.cr
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ module PlaceOS::Model

table :alert_dashboard

attribute name : String, es_subfield: "keyword"
attribute description : String = ""
attribute name : String, sanitize: :text, es_subfield: "keyword"
attribute description : String = "", sanitize: :common
attribute enabled : Bool = true

# Association
Expand Down
6 changes: 3 additions & 3 deletions src/placeos-models/api_key.cr
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ module PlaceOS::Model

table :api_key

attribute name : String, es_subfield: "keyword"
attribute description : String = ""
attribute name : String, sanitize: :text, es_subfield: "keyword"
attribute description : String = "", sanitize: :common

attribute scopes : Array(UserJWT::Scope) = [UserJWT::Scope::PUBLIC], converter: PlaceOS::Model::DBArrConverter(PlaceOS::Model::UserJWT::Scope), es_type: "keyword"

Expand Down Expand Up @@ -78,7 +78,7 @@ module PlaceOS::Model

@[JSON::Field(ignore: true)]
getter x_api_key : String? do
return nil if self.persisted?
return if self.persisted?
"#{self.safe_id}.#{self.secret}"
end

Expand Down
18 changes: 14 additions & 4 deletions src/placeos-models/asset.cr
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
require "./base/model"
require "./asset_type"
require "./asset_purchase_order"
require "./utilities/sanitization"

module PlaceOS::Model
class Asset < ModelBase
Expand All @@ -13,19 +14,19 @@ module PlaceOS::Model
attribute other_data : JSON::Any?
attribute barcode : String?

attribute name : String?
attribute name : String?, sanitize: :text
attribute client_ids : JSON::Any? # {floorsense_id: "", other_id: ""} etc
attribute map_id : String?
attribute bookable : Bool = true
attribute accessible : Bool = false
attribute zones : Array(String) = [] of String, es_type: "keyword"
attribute place_groups : Array(String) = [] of String, es_type: "keyword"
attribute assigned_to : String? # email
attribute assigned_name : String? # name of user
attribute assigned_to : String? # email
attribute assigned_name : String?, sanitize: :text # name of user
# queryable with AND and OR operators
attribute features : Array(String) = [] of String, es_type: "keyword"
attribute images : Array(String) = [] of String, es_type: "keyword"
attribute notes : String? # email
attribute notes : String?, sanitize: :common # email
attribute security_system_groups : Array(String) = [] of String, es_type: "keyword"

# attribute parent_id : String? # nested resource like lockers and locker banks
Expand All @@ -41,6 +42,15 @@ module PlaceOS::Model
validates :asset_type_id, presence: true
validates :zone_id, presence: true

before_save do
if (data = @other_data) && @other_data_changed
@other_data = Sanitization.sanitize_strings(data)
end
if (feat = @features) && @features_changed
@features = Sanitization.sanitize_strings(feat)
end
end

before_destroy :cleanup_bookings

# Reject any bookings that are current
Expand Down
2 changes: 1 addition & 1 deletion src/placeos-models/asset_category.cr
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ module PlaceOS::Model
table :asset_category

# i.e. a tablet
attribute name : String, es_subfield: "keyword"
attribute name : String, sanitize: :text, es_subfield: "keyword"
attribute description : String?
attribute hidden : Bool = false, es_subfield: "keyword"

Expand Down
11 changes: 9 additions & 2 deletions src/placeos-models/asset_purchase_order.cr
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
require "./base/model"
require "./asset"
require "./utilities/sanitization"

module PlaceOS::Model
class AssetPurchaseOrder < ModelBase
include PlaceOS::Model::Timestamps

table :asset_purchase_order

attribute purchase_order_number : String, es_type: "keyword"
attribute invoice_number : String?
attribute purchase_order_number : String, sanitize: :text, es_type: "keyword"
attribute invoice_number : String?, sanitize: :text
attribute supplier_details : JSON::Any?
attribute purchase_date : Int64?

Expand All @@ -22,6 +23,12 @@ module PlaceOS::Model
collection_name: :assets
)

before_save do
if (data = @supplier_details) && @supplier_details_changed
@supplier_details = Sanitization.sanitize_strings(data)
end
end

# Validation
###############################################################################################

Expand Down
8 changes: 4 additions & 4 deletions src/placeos-models/asset_type.cr
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,10 @@ module PlaceOS::Model

table :asset_type

attribute name : String, es_subfield: "keyword"
attribute brand : String
attribute description : String?
attribute model_number : String?
attribute name : String, sanitize: :text, es_subfield: "keyword"
attribute brand : String, sanitize: :text
attribute description : String?, sanitize: :common
attribute model_number : String?, sanitize: :text
attribute images : Array(String)? = [] of String

belongs_to AssetCategory, foreign_key: "category_id", association_name: "category"
Expand Down
Loading
Loading