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:76 — Migration.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
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).
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.cfcwas occupied by PR #2910 (JOIN-memo cache key) — that PR has since merged, so this work is now unblocked. Line numbers verified againstorigin/develop@8971094except where noted.1.
$sanitizeScopeHandlerArgssilently corrupts legitimate values (M9 / SEC-22)vendor/wheels/model/properties.cfc:763(invoked fromScopeChain.cfc:209): a blocklist strips--,/*,;and whole-wordUNION/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 like0 OR 1=1survive it.3. SELECT clause passes dotted/aliased items through with zero validation (SEC-21)
vendor/wheels/model/sql.cfc:501appends any select-list item containing.orASverbatim, while$orderByClauseand$groupByClausevalidate their inputs. SofindAll(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.Migrationper WHERE build just to read the dialect name (M3, held)vendor/wheels/model/sql.cfc:712,:766andupdate.cfc:76—Migration.init()issues an uncached$dbinfo(type="version")(plusSELECT version()on Postgres) on everyupdateAll/deleteAlland everyfindAllshell-cache miss, solely to calladapter.adapterName()— which the model already caches and uses atsql.cfc:27.5.
updateAll(include=…)builds WHERE viaJOIN.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 likeTRANSACTIONS/POSITIONID);classes[2]hard-index ignores additional includes; the loops increment an unscopedi.Impact
select=from request params is a working exfiltration primitive.updateAll/deleteAllpays a$dbinforound-trip; uppercase-identifier schemas get corrupted JOIN conditions inupdateAll(include=).Suggested approach
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 passtable.columnandexpr AS aliasitems. Needs a deliberate policy decision (deprecation window or dev-mode warning) before hard-rejecting.get("adapterName"), or cache it at$assignAdaptertime, instead of instantiating Migration per WHERE build.classes[2]; scope the loop counter aslocal.i.Acceptance criteria
select=validation exists and is enforced;findAll(select="id, (SELECT secret FROM users) AS x")is rejected.Migrationinstantiation (and no$dbinfocall) per WHERE build; updateAll/deleteAll hot path verified.updateAll(include=)works with uppercase identifiers and multiple includes; loop counters scoped.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.cfcoccupied by PR #2910, since merged).