Skip to content

fix(model): block SQL injection in chainable QueryBuilder via value-shape validation#2416

Merged
bpamiri merged 1 commit into
developfrom
claude/adoring-austin-b3673e
May 3, 2026
Merged

fix(model): block SQL injection in chainable QueryBuilder via value-shape validation#2416
bpamiri merged 1 commit into
developfrom
claude/adoring-austin-b3673e

Conversation

@bpamiri

@bpamiri bpamiri commented May 3, 2026

Copy link
Copy Markdown
Collaborator

Summary

Pre-release security review for v4.0.0-rc1 surfaced a HIGH-severity SQL injection in the new chainable QueryBuilder — the API advertised in CLAUDE.md and its own docblock as "injection-safe."

The adapter's $quoteValue returns integer/float/boolean values unquoted, on the assumption that callers have already constrained the value to a numeric/boolean shape. The QueryBuilder didn't enforce that constraint. A standard endpoint like:

function index() {
    users = model("User").where("age", ">", params.age).get();
}

/users?age=0%20OR%201%3D1 produced WHERE age > 0 OR 1=1 — a tautology returning every row. The same defect affected whereBetween (per-bound), whereIn / whereNotIn (per-element), and the 2-arg where()/orWhere() forms.

What changed

  • QueryBuilder.cfc: $quoteValue now calls $validateValueShape before delegating to the adapter. Integer values must match ^-?[0-9]+$, floats must match ^-?[0-9]+(\.[0-9]+)?$, booleans must be in 0,1,true,false,yes,no. Mismatches throw Wheels.InvalidValue. String columns are unaffected — the adapter still quotes and escapes them.
  • QueryBuilderSpec.cfc: 22 regression tests covering tautology, UNION SELECT, comment-truncation, stacked-statement, per-method coverage across where, orWhere, whereBetween, whereIn, whereNotIn — plus accept-cases for legitimate integer/float/string values.

Out of scope (separate finding)

The same root-cause pattern exists in legacy paths that pre-date the chainable builder:

  • vendor/wheels/model/onmissingmethod.cfc:165 — dynamic finders (findByViews(params.views))
  • vendor/wheels/model/sql.cfc:1325$keyWhereString used by findByKey / updateByKey / deleteByKey

These are vulnerable today (findByKey("1 OR 1=1") injects on integer-keyed models), but they ship in every Wheels release going back to before 4.0. Recommend tracking as a follow-up so the rc1 fix stays narrowly scoped to the new "injection-safe" surface.

Test plan

  • CI: full test matrix on pr.yml (Lucee 5/6/7 + Adobe 2018/2021/2023/2025 + boxlang × SQLite/MySQL/Postgres/SQL Server)
  • Verify no regression in model/queryBuilderSpec.cfc integration tests (existing legitimate values: where("views", ">", 0), whereBetween("views", 1, 5), whereIn("lastName", "Djurner,Petruzzi") all match the new validation)
  • New tests in QueryBuilderSpec.cfc pass on all engines

Note on local verification: I could not run the suite locally — LuCLI hardcodes shutdown port 8081, which is held by another worktree's daily server. Did not bounce that server. Fix verified by close code review against the test suite.

🤖 Generated with Claude Code

…k SQL injection

The chainable QueryBuilder advertised in CLAUDE.md and its own docblock as
"injection-safe" relied on the adapter's $quoteValue, which passes integer,
float, and boolean values through unquoted. Without a value-shape check at the
builder boundary, a string like "0 OR 1=1" landed verbatim in the WHERE clause
on integer columns and bypassed the post-hoc cfqueryparam pass — a tautology
injection that would return every row. The same defect affected whereBetween
(per-bound), whereIn / whereNotIn (per-element), and the 2-arg where()/orWhere().

Validate values against the declared validationtype in $quoteValue. Integer and
float values must match a numeric regex; booleans must be one of 0/1/true/false/
yes/no. Mismatches throw Wheels.InvalidValue. String columns are unaffected —
the adapter still quotes-and-escapes them.

Adds 22 regression tests in QueryBuilderSpec.cfc covering tautology, UNION SELECT,
comment-truncation, stacked-statement, and per-method coverage across where,
orWhere, whereBetween, whereIn, whereNotIn — plus accept-cases for legitimate
integer/float/string values.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@bpamiri
bpamiri merged commit 2cd869d into develop May 3, 2026
3 checks passed
@bpamiri
bpamiri deleted the claude/adoring-austin-b3673e branch May 3, 2026 02:29
bpamiri added a commit that referenced this pull request May 3, 2026
…lue 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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant