From a756cf07a56a33163f7dfddb2dd89e2d3d8a8efc Mon Sep 17 00:00:00 2001 From: Peter Amiri Date: Thu, 9 Apr 2026 21:20:30 -0700 Subject: [PATCH 1/2] fix(model): validate calculated property sql at config time Adds $validateCalculatedPropertySql() that rejects dangerous SQL patterns (UNION, EXEC, xp_, SLEEP, BENCHMARK, LOAD_FILE, INTO OUTFILE/DUMPFILE) when property(sql="...") is called during model config. Defense-in-depth against supply-chain or accidental injection of user input into calculated property SQL expressions. --- vendor/wheels/model/properties.cfc | 22 +++ .../security/CalculatedPropertySqlSpec.cfc | 136 ++++++++++++++++++ 2 files changed, 158 insertions(+) create mode 100644 vendor/wheels/tests/specs/security/CalculatedPropertySqlSpec.cfc diff --git a/vendor/wheels/model/properties.cfc b/vendor/wheels/model/properties.cfc index 986c50ba0b..12c4700726 100644 --- a/vendor/wheels/model/properties.cfc +++ b/vendor/wheels/model/properties.cfc @@ -104,6 +104,7 @@ component { variables.wheels.class.mapping[arguments.name].value = arguments.column; } if (Len(arguments.sql)) { + $validateCalculatedPropertySql(sql=arguments.sql, propertyName=arguments.name); variables.wheels.class.mapping[arguments.name].type = "sql"; variables.wheels.class.mapping[arguments.name].value = arguments.sql; variables.wheels.class.mapping[arguments.name].select = arguments.select; @@ -906,4 +907,25 @@ component { variables.wheels.class.scopes[local.name] = local.scopeDef; } } + + /** + * Validates that a calculated property SQL expression does not contain dangerous patterns. + * Called at model config time when property(sql="...") is used. This is a defense-in-depth + * measure: calculated property SQL is developer-defined, but this catches supply-chain attacks + * or accidental interpolation of user input into SQL expressions. + * + * [section: Model Configuration] + * [category: Miscellaneous Functions] + */ + public string function $validateCalculatedPropertySql(required string sql, required string propertyName) { + local.dangerous = ";\s|UNION\s|INTO\s+(?:OUT|DUMP)|EXEC\s|xp_|LOAD_FILE|BENCHMARK|SLEEP\s*\("; + if (ReFindNoCase(local.dangerous, arguments.sql)) { + Throw( + type = "Wheels.InvalidCalculatedProperty", + message = "The calculated property `#arguments.propertyName#` contains potentially dangerous SQL patterns.", + extendedInfo = "Calculated property SQL must not contain semicolons followed by whitespace, UNION, EXEC, or other dangerous SQL constructs. Expression: #arguments.sql#" + ); + } + return arguments.sql; + } } diff --git a/vendor/wheels/tests/specs/security/CalculatedPropertySqlSpec.cfc b/vendor/wheels/tests/specs/security/CalculatedPropertySqlSpec.cfc new file mode 100644 index 0000000000..864ae9f82e --- /dev/null +++ b/vendor/wheels/tests/specs/security/CalculatedPropertySqlSpec.cfc @@ -0,0 +1,136 @@ +component extends="wheels.WheelsTest" { + + function run() { + + g = application.wo + + describe("Calculated property SQL injection prevention", () => { + + beforeEach(() => { + g.$clearModelInitializationCache() + }) + + afterEach(() => { + g.$clearModelInitializationCache() + }) + + describe("$validateCalculatedPropertySql", () => { + + it("rejects SQL with semicolons followed by whitespace", () => { + expect(function() { + var m = g.model("post") + m.$validateCalculatedPropertySql(sql="firstName; DROP TABLE users", propertyName="test") + }).toThrow("Wheels.InvalidCalculatedProperty") + }) + + it("rejects SQL with UNION SELECT", () => { + expect(function() { + var m = g.model("post") + m.$validateCalculatedPropertySql(sql="firstName UNION SELECT password FROM admins", propertyName="test") + }).toThrow("Wheels.InvalidCalculatedProperty") + }) + + it("rejects SQL with EXEC", () => { + expect(function() { + var m = g.model("post") + m.$validateCalculatedPropertySql(sql="EXEC sp_executesql N'SELECT 1'", propertyName="test") + }).toThrow("Wheels.InvalidCalculatedProperty") + }) + + it("rejects SQL with xp_ extended stored procedures", () => { + expect(function() { + var m = g.model("post") + m.$validateCalculatedPropertySql(sql="xp_cmdshell('dir')", propertyName="test") + }).toThrow("Wheels.InvalidCalculatedProperty") + }) + + it("rejects SQL with SLEEP function", () => { + expect(function() { + var m = g.model("post") + m.$validateCalculatedPropertySql(sql="SLEEP(5)", propertyName="test") + }).toThrow("Wheels.InvalidCalculatedProperty") + }) + + it("rejects SQL with BENCHMARK", () => { + expect(function() { + var m = g.model("post") + m.$validateCalculatedPropertySql(sql="BENCHMARK(1000000, SHA1('test'))", propertyName="test") + }).toThrow("Wheels.InvalidCalculatedProperty") + }) + + it("rejects SQL with LOAD_FILE", () => { + expect(function() { + var m = g.model("post") + m.$validateCalculatedPropertySql(sql="LOAD_FILE('/etc/passwd')", propertyName="test") + }).toThrow("Wheels.InvalidCalculatedProperty") + }) + + it("rejects SQL with INTO OUTFILE", () => { + expect(function() { + var m = g.model("post") + m.$validateCalculatedPropertySql(sql="SELECT 1 INTO OUTFILE '/tmp/data.txt'", propertyName="test") + }).toThrow("Wheels.InvalidCalculatedProperty") + }) + + it("rejects SQL with INTO DUMPFILE", () => { + expect(function() { + var m = g.model("post") + m.$validateCalculatedPropertySql(sql="SELECT 1 INTO DUMPFILE '/tmp/data.bin'", propertyName="test") + }).toThrow("Wheels.InvalidCalculatedProperty") + }) + + it("allows legitimate CONCAT expression", () => { + var m = g.model("post") + var result = m.$validateCalculatedPropertySql(sql="CONCAT(firstName, ' ', lastName)", propertyName="fullName") + expect(result).toBe("CONCAT(firstName, ' ', lastName)") + }) + + it("allows legitimate CASE expression", () => { + var m = g.model("post") + var result = m.$validateCalculatedPropertySql(sql="CASE WHEN status = 1 THEN 'active' ELSE 'inactive' END", propertyName="statusLabel") + expect(result).toBe("CASE WHEN status = 1 THEN 'active' ELSE 'inactive' END") + }) + + it("allows aggregate functions", () => { + var m = g.model("post") + var result = m.$validateCalculatedPropertySql(sql="COUNT(comments.id)", propertyName="commentCount") + expect(result).toBe("COUNT(comments.id)") + }) + + it("allows subselect without dangerous patterns", () => { + var m = g.model("post") + var result = m.$validateCalculatedPropertySql(sql="(SELECT COUNT(*) FROM comments WHERE comments.postId = posts.id)", propertyName="commentCount") + expect(result).toBe("(SELECT COUNT(*) FROM comments WHERE comments.postId = posts.id)") + }) + + it("allows COALESCE expression", () => { + var m = g.model("post") + var result = m.$validateCalculatedPropertySql(sql="COALESCE(nickname, firstName)", propertyName="displayName") + expect(result).toBe("COALESCE(nickname, firstName)") + }) + + }) + + describe("property() integration", () => { + + it("throws when defining a calculated property with dangerous SQL", () => { + expect(function() { + var m = g.model("post") + m.property(name="evil", sql="firstName; DROP TABLE users") + }).toThrow("Wheels.InvalidCalculatedProperty") + }) + + it("allows defining a calculated property with safe SQL", () => { + var m = g.model("post") + m.property(name="fullName", sql="CONCAT(firstName, ' ', lastName)") + // should not throw + expect(true).toBeTrue() + }) + + }) + + }) + + } + +} From 11668ee2d4eca19ebe9544d52911e07b3621acd4 Mon Sep 17 00:00:00 2001 From: Peter Amiri Date: Fri, 10 Apr 2026 01:12:44 -0700 Subject: [PATCH 2/2] fix(model): tighten calculated property sql validation regex - Change semicolon pattern from ;\s to bare ; (no legitimate semicolons in calculated property expressions) - Change EXEC\s to \bEXEC(UTE)?\b to catch both EXEC and EXECUTE as whole words, including EXECUTE('dynamic sql') without spaces - Add \b word boundaries to UNION to prevent false positives - Add tests for bare semicolons and EXECUTE keyword --- vendor/wheels/model/properties.cfc | 4 ++-- .../specs/security/CalculatedPropertySqlSpec.cfc | 14 ++++++++++++++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/vendor/wheels/model/properties.cfc b/vendor/wheels/model/properties.cfc index 12c4700726..4aabdbf232 100644 --- a/vendor/wheels/model/properties.cfc +++ b/vendor/wheels/model/properties.cfc @@ -918,12 +918,12 @@ component { * [category: Miscellaneous Functions] */ public string function $validateCalculatedPropertySql(required string sql, required string propertyName) { - local.dangerous = ";\s|UNION\s|INTO\s+(?:OUT|DUMP)|EXEC\s|xp_|LOAD_FILE|BENCHMARK|SLEEP\s*\("; + local.dangerous = ";|\bUNION\b|INTO\s+(?:OUT|DUMP)|\bEXEC(UTE)?\b|xp_|LOAD_FILE|BENCHMARK|SLEEP\s*\("; if (ReFindNoCase(local.dangerous, arguments.sql)) { Throw( type = "Wheels.InvalidCalculatedProperty", message = "The calculated property `#arguments.propertyName#` contains potentially dangerous SQL patterns.", - extendedInfo = "Calculated property SQL must not contain semicolons followed by whitespace, UNION, EXEC, or other dangerous SQL constructs. Expression: #arguments.sql#" + extendedInfo = "Calculated property SQL must not contain semicolons, UNION, EXEC/EXECUTE, or other dangerous SQL constructs. Expression: #arguments.sql#" ); } return arguments.sql; diff --git a/vendor/wheels/tests/specs/security/CalculatedPropertySqlSpec.cfc b/vendor/wheels/tests/specs/security/CalculatedPropertySqlSpec.cfc index 864ae9f82e..08d8a13b37 100644 --- a/vendor/wheels/tests/specs/security/CalculatedPropertySqlSpec.cfc +++ b/vendor/wheels/tests/specs/security/CalculatedPropertySqlSpec.cfc @@ -23,6 +23,20 @@ component extends="wheels.WheelsTest" { }).toThrow("Wheels.InvalidCalculatedProperty") }) + it("rejects SQL with bare trailing semicolons", () => { + expect(function() { + var m = g.model("post") + m.$validateCalculatedPropertySql(sql="COUNT(*);", propertyName="test") + }).toThrow("Wheels.InvalidCalculatedProperty") + }) + + it("rejects SQL with EXECUTE keyword", () => { + expect(function() { + var m = g.model("post") + m.$validateCalculatedPropertySql(sql="EXECUTE('SELECT 1')", propertyName="test") + }).toThrow("Wheels.InvalidCalculatedProperty") + }) + it("rejects SQL with UNION SELECT", () => { expect(function() { var m = g.model("post")