Skip to content

Commit 5860ecd

Browse files
bpamiriclaude
andauthored
fix(model): block SQL injection in legacy finders via adapter-side value validation (#2417) (#2418)
PR #2416 closed the chainable QueryBuilder's SQL injection by validating typed values before $quoteValue. The same root-cause pattern still existed in the legacy ORM paths that pre-date the chainable builder: - findByKey / updateByKey / deleteByKey via $keyWhereString (sql.cfc:1325) - findByX / findOneByX / findAllByX via dynamic finders (onmissingmethod.cfc:165) - $buildWhereClausePart used by validatesUniquenessOf (validations.cfc:774) All three paths reach the adapter's $quoteValue with user-supplied values bound to integer/float/boolean columns, where the adapter passes them through unquoted. The downstream RESQLWhere regex re-extracts bare numerics into cfqueryparam placeholders but leaves any injected OR / UNION / comment structurally intact — so /users/1%20OR%201%3D1 produced WHERE id=1 OR 1=1, a tautology returning the first row regardless of the intended key. Push the validation into Base.$quoteValue itself so every caller (including any future one) inherits the protection. The same regex/allowlist as #2416 — ^-?[0-9]+$ for integer, ^-?[0-9]+(\.[0-9]+)?$ for float, 0/1/true/false/yes/no for boolean. Mismatches throw Wheels.InvalidValue. String columns are untouched; the adapter still wraps + escapes them. Adapter-side rather than caller-side per the audit notes in #2417: read.cfc:170 round-trips DB-stored PK values (always valid integers); validations.cfc:774 feeds model property values (a non-numeric on an integer column was already a DB error today, now it's a clearer Wheels.InvalidValue at the framework boundary); Base.cfc:66 fires only on parameterize=false query plans where malformed numerics were also already broken. Adds 16 regression tests in vendor/wheels/tests/specs/security/ LegacyFinderInjectionSpec.cfc covering tautology, UNION SELECT, comment- truncation, and stacked-statement payloads against findByKey / updateByKey / deleteByKey / findAllByX / findOneByX on integer + float columns, plus accept-cases for legitimate integer/float/string values. Closes #2417. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 2cd869d commit 5860ecd

2 files changed

Lines changed: 185 additions & 0 deletions

File tree

vendor/wheels/databaseAdapters/Base.cfc

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -472,6 +472,18 @@ component output=false extends="wheels.Global"{
472472

473473
/**
474474
* Internal function.
475+
*
476+
* For integer/float/boolean columns this returns the value unquoted so the
477+
* downstream WHERE-clause regex can re-extract bare numerics into
478+
* cfqueryparam placeholders. That contract is unsafe by itself — a string
479+
* like "0 OR 1=1" would land verbatim in the SQL — so we validate the value
480+
* shape here before passing it through. This closes SQL injection across
481+
* every caller (chainable QueryBuilder, $keyWhereString used by findByKey/
482+
* updateByKey/deleteByKey, dynamic finders findByX/findOneByX/findAllByX,
483+
* the uniqueness-check $buildWhereClausePart, and any future caller).
484+
*
485+
* String columns are unaffected — the adapter still wraps and escapes the
486+
* value, so classic single-quote payloads land harmlessly inside a literal.
475487
*/
476488
public string function $quoteValue(required string str, string sqlType = "CF_SQL_VARCHAR", string type) {
477489
if (!StructKeyExists(arguments, "type")) {
@@ -480,11 +492,50 @@ component output=false extends="wheels.Global"{
480492
if (!ListFindNoCase("integer,float,boolean", arguments.type) || !Len(arguments.str)) {
481493
local.rv = "'#Replace(arguments.str, "'", "''", "all")#'";
482494
} else {
495+
$validateValueShape(arguments.str, arguments.type);
483496
local.rv = arguments.str;
484497
}
485498
return local.rv;
486499
}
487500

501+
/**
502+
* Validate that a value matches the shape this adapter expects for its
503+
* declared column type. Throws Wheels.InvalidValue when the shape doesn't
504+
* match — closing the SQL-injection vector through which strings like
505+
* "0 OR 1=1" would otherwise land in the unquoted numeric/boolean path.
506+
*
507+
* Intentionally narrow: only fires for integer/float/boolean columns,
508+
* which is exactly the set of types $quoteValue passes through unquoted.
509+
* String columns are wrapped + escaped above and don't need this check.
510+
*/
511+
public void function $validateValueShape(required string str, required string type) {
512+
switch (arguments.type) {
513+
case "integer":
514+
if (!ReFind("^-?[0-9]+$", arguments.str)) {
515+
$throwInvalidValue(arguments.str, "integer");
516+
}
517+
break;
518+
case "float":
519+
if (!ReFind("^-?[0-9]+(\.[0-9]+)?$", arguments.str)) {
520+
$throwInvalidValue(arguments.str, "float");
521+
}
522+
break;
523+
case "boolean":
524+
if (!ListFindNoCase("0,1,true,false,yes,no", arguments.str)) {
525+
$throwInvalidValue(arguments.str, "boolean");
526+
}
527+
break;
528+
}
529+
}
530+
531+
public void function $throwInvalidValue(required string str, required string expectedType) {
532+
Throw(
533+
type = "Wheels.InvalidValue",
534+
message = "The value `#EncodeForHTML(arguments.str)#` is not a valid #arguments.expectedType#.",
535+
extendedInfo = "Values bound to #arguments.expectedType# columns must be valid #arguments.expectedType# literals so they can be safely interpolated into the WHERE clause. This check protects every Wheels query path against SQL injection through typed-numeric/boolean payloads."
536+
);
537+
}
538+
488539
/**
489540
* Acquire a database advisory lock with the given name.
490541
* Advisory locks are application-level locks that don't lock rows or tables.
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
component extends="wheels.WheelsTest" {
2+
3+
function run() {
4+
5+
g = application.wo
6+
7+
describe("Legacy finder SQL injection prevention", () => {
8+
9+
// PR #2416 closed the SQL-injection vector in the chainable QueryBuilder by
10+
// validating typed values before they reach the adapter's $quoteValue. The
11+
// same root-cause pattern existed in the legacy ORM paths that pre-date the
12+
// chainable builder — findByKey / updateByKey / deleteByKey (which share the
13+
// $keyWhereString sink in sql.cfc) and the dynamic finders (findByX /
14+
// findOneByX / findAllByX) in onmissingmethod.cfc. Adapter-side validation
15+
// in Base.$quoteValue closes those at the only sink they share.
16+
//
17+
// Tracking issue: #2417.
18+
19+
describe("$keyWhereString — findByKey / updateByKey / deleteByKey", () => {
20+
21+
it("findByKey rejects tautology injection on integer-keyed model", () => {
22+
expect(function() {
23+
g.model("post").findByKey("1 OR 1=1");
24+
}).toThrow("Wheels.InvalidValue");
25+
});
26+
27+
it("findByKey rejects UNION SELECT injection", () => {
28+
expect(function() {
29+
g.model("post").findByKey("1 UNION SELECT password FROM c_o_r_e_users");
30+
}).toThrow("Wheels.InvalidValue");
31+
});
32+
33+
it("findByKey rejects comment-truncation injection", () => {
34+
expect(function() {
35+
g.model("post").findByKey("1 --");
36+
}).toThrow("Wheels.InvalidValue");
37+
});
38+
39+
it("findByKey rejects stacked-statement injection", () => {
40+
expect(function() {
41+
g.model("post").findByKey("1; DROP TABLE c_o_r_e_posts");
42+
}).toThrow("Wheels.InvalidValue");
43+
});
44+
45+
it("updateByKey rejects tautology injection in key", () => {
46+
expect(function() {
47+
g.model("post").updateByKey(key = "1 OR 1=1", title = "hijacked");
48+
}).toThrow("Wheels.InvalidValue");
49+
});
50+
51+
it("deleteByKey rejects tautology injection in key", () => {
52+
expect(function() {
53+
g.model("post").deleteByKey("1 OR 1=1");
54+
}).toThrow("Wheels.InvalidValue");
55+
});
56+
57+
it("findByKey accepts a legitimate integer key", () => {
58+
// Should not throw; the row may or may not exist depending on
59+
// fixtures, but the value-shape gate must let integers through.
60+
expect(function() {
61+
g.model("post").findByKey(1);
62+
}).notToThrow();
63+
});
64+
65+
it("findByKey accepts an integer-shaped string key", () => {
66+
expect(function() {
67+
g.model("post").findByKey("1");
68+
}).notToThrow();
69+
});
70+
71+
it("findByKey accepts a negative integer key", () => {
72+
// Negative integers are legitimate per the regex (^-?[0-9]+$).
73+
// No row will match, but the gate must not throw.
74+
expect(function() {
75+
g.model("post").findByKey("-1");
76+
}).notToThrow();
77+
});
78+
79+
});
80+
81+
describe("dynamic finders — findByX / findOneByX / findAllByX", () => {
82+
83+
it("findAllByViews rejects tautology injection on integer column", () => {
84+
expect(function() {
85+
g.model("post").findAllByViews("0 OR 1=1");
86+
}).toThrow("Wheels.InvalidValue");
87+
});
88+
89+
it("findOneByViews rejects UNION SELECT injection on integer column", () => {
90+
expect(function() {
91+
g.model("post").findOneByViews("0 UNION SELECT 1");
92+
}).toThrow("Wheels.InvalidValue");
93+
});
94+
95+
it("findOneByViews rejects comment-truncation on integer column", () => {
96+
expect(function() {
97+
g.model("post").findOneByViews("0 --");
98+
}).toThrow("Wheels.InvalidValue");
99+
});
100+
101+
it("findAllByAveragerating rejects injection on float column", () => {
102+
expect(function() {
103+
g.model("post").findAllByAveragerating("3.14 OR 1=1");
104+
}).toThrow("Wheels.InvalidValue");
105+
});
106+
107+
it("findAllByViews accepts legitimate integer value", () => {
108+
expect(function() {
109+
g.model("post").findAllByViews(0);
110+
}).notToThrow();
111+
});
112+
113+
it("findAllByAveragerating accepts legitimate float value", () => {
114+
expect(function() {
115+
g.model("post").findAllByAveragerating(3.14);
116+
}).notToThrow();
117+
});
118+
119+
it("findAllByTitle accepts ordinary string values (string column)", () => {
120+
// String columns are quoted-and-escaped by the adapter; the
121+
// numeric/boolean shape gate is skipped for them so legitimate
122+
// string content (including spaces) passes through.
123+
expect(function() {
124+
g.model("post").findAllByTitle("any title here");
125+
}).notToThrow();
126+
});
127+
128+
});
129+
130+
});
131+
132+
}
133+
134+
}

0 commit comments

Comments
 (0)