Skip to content

review remediation: model SQL layer — replace scope-arg denylist with parameterization, define select= validation policy, drop per-WHERE Migration instantiation (held: sql.cfc occupied by PR #2910) #2952

Description

@bpamiri

Problem

Three injection-surface design defects plus two held perf/quality defects in the model SQL layer. All were held out of wave 2 because vendor/wheels/model/sql.cfc was occupied by PR #2910 (JOIN-memo cache key) — that PR has since merged, so this work is now unblocked. Line numbers verified against origin/develop @ 8971094 except where noted.

1. $sanitizeScopeHandlerArgs silently corrupts legitimate values (M9 / SEC-22)
vendor/wheels/model/properties.cfc:763 (invoked from ScopeChain.cfc:209): a blocklist strips --, /*, ; and whole-word UNION/EXEC/SLEEP/etc. from every simple scope-handler arg, mutating data in place — "Union Pacific" becomes " Pacific", "Estimated delay" loses "delay". The code's own comment admits it is a stopgap.

2. The denylist is also bypassable (SEC-22)
The sanitizer is quote-escaping plus an incomplete SQL keyword denylist. Quote-escaping protects only quoted contexts; an arg interpolated unquoted/numeric (where="age > #args.age#") gets only the denylist, and inputs like 0 OR 1=1 survive it.

3. SELECT clause passes dotted/aliased items through with zero validation (SEC-21)
vendor/wheels/model/sql.cfc:501 appends any select-list item containing . or AS verbatim, while $orderByClause and $groupByClause validate their inputs. So findAll(select=params.fields) — API field-selection — becomes an injection/exfiltration primitive (e.g. id, (SELECT secret FROM users) AS x).

4. SQL layer instantiates wheels.migrator.Migration per WHERE build just to read the dialect name (M3, held)
vendor/wheels/model/sql.cfc:712, :766 and update.cfc:76Migration.init() issues an uncached $dbinfo(type="version") (plus SELECT version() on Postgres) on every updateAll/deleteAll and every findAll shell-cache miss, solely to call adapter.adapterName() — which the model already caches and uses at sql.cfc:27.

5. updateAll(include=…) builds WHERE via JOIN.Split("ON")[2] string surgery (M4, held)
sql.cfc:770, :785 (review-time lines): a case-sensitive bare-token split on "ON" corrupts joins on uppercase identifiers (H2 preserves uppercase; tables/columns like TRANSACTIONS/POSITIONID); classes[2] hard-index ignores additional includes; the loops increment an unscoped i.

Impact

  • Legitimate user data is silently rewritten before hitting the database (data-integrity bug).
  • The sanitizer creates a false sense of safety for unquoted interpolation contexts; select= from request params is a working exfiltration primitive.
  • Every updateAll/deleteAll pays a $dbinfo round-trip; uppercase-identifier schemas get corrupted JOIN conditions in updateAll(include=).

Suggested approach

  • Route scope-handler values through parameterized binding; reserve rejection (throw) for the truly unparameterizable; never silently rewrite user data. Document that the sanitizer does not make unquoted interpolation safe.
  • Define a validation policy for dotted/aliased select= items: apply the same ; / -- / comment / subquery-paren rejection and dot-notation regex used by the ORDER BY/GROUP BY paths, without breaking existing apps that legitimately pass table.column and expr AS alias items. Needs a deliberate policy decision (deprecation window or dev-mode warning) before hard-rejecting.
  • Derive the dialect name by stripping "Model" from get("adapterName"), or cache it at $assignAdapter time, instead of instantiating Migration per WHERE build.
  • Split the JOIN on the ON keyword with position arithmetic or store the ON-condition on the association struct; loop over all associations instead of hard-indexing classes[2]; scope the loop counter as local.i.

Acceptance criteria

  • Scope-handler args reach SQL via parameterized binding (or throw); "Union Pacific" round-trips unchanged through a scope filter.
  • A documented policy for select= validation exists and is enforced; findAll(select="id, (SELECT secret FROM users) AS x") is rejected.
  • No Migration instantiation (and no $dbinfo call) per WHERE build; updateAll/deleteAll hot path verified.
  • updateAll(include=) works with uppercase identifiers and multiple includes; loop counters scoped.
  • Cross-engine specs cover the above (bash tools/test-local.sh model + matrix spot-check).

Source

Internal multi-agent framework review 2026-06-09, wave 2 (issues phase). Findings: model-orm M9, security-pass SEC-21/SEC-22; held package model-orm M3/M4 (held: model/sql.cfc occupied by PR #2910, since merged).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions