Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions vendor/wheels/model/properties.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 = ";|\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, UNION, EXEC/EXECUTE, or other dangerous SQL constructs. Expression: #arguments.sql#"
);
}
return arguments.sql;
}
}
150 changes: 150 additions & 0 deletions vendor/wheels/tests/specs/security/CalculatedPropertySqlSpec.cfc
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
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 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")
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()
})

})

})

}

}
Loading