Closes #1181.
Nine methods interpolated caller-supplied column names straight into SQL. Measured against live Postgres before the fix:
table exists before : true
SQL built : SELECT * FROM sec_users ORDER BY id; DROP TABLE sec_users; -- ASC
table exists after : falseOne call — db.selectFrom('sec_users').orderBy(sortParam).get(). The stacked statement ran.
After:
TypeError: [query-builder] orderBy(column): 'id; DROP TABLE sec_users; --' is not a plain
column reference, so it will not be interpolated into SQL. Expected `column`,
`table.column`, optionally with `AS alias`. For an expression use the matching *Raw method
or the `raw` tagged template, which escape what they interpolate.Why the existing code looked safe
quoteColumnForDialect is a MySQL quoter, not a validator. On Postgres and SQLite it returns its argument untouched, and on MySQL it returns anything it cannot parse verbatim (if (!parsed) return name). Every caller that passed a column name through it was interpolating that name raw.
This was a gap in an existing defence: validateIdentifier already guards where, whereIn, whereNull, join, wherePivot and every relationship subquery, and orm.ts:2354 closed the ORDER BY half after stacksjs/stacks#1858.
The fix
assertSafeColumnRef applies the same shape the quoter already parses — column, table.column, optional AS alias, and * only where a star is meaningful — then quotes. Applied at all nine sites.
| before | after | |
|---|---|---|
orderBy / orderByDesc / latest / oldest | injected | throws |
groupBy / distinctOn | injected | throws |
select / addSelect | injected | throws |
selectFrom | injected | throws |
injectable surfaces: 0/14One hole the first pass missed
addSelect is variadic, so addSelect(['a']) — the shape select takes — arrived as a single array argument, missed the string branch, and reached renderSelectColumn's String(col) fallback whole and unvalidated. It now recurses into arrays, and the stringify fallback validates too. There is a regression test for exactly that shape.
I only caught this because I re-ran the full surface map after fixing the other eight rather than assuming the common hook covered them.
Expressions
COUNT(*) AS c and friends keep working through the *Raw methods and the raw tagged template, which escape what they interpolate. Fragments bypass the identifier check by design rather than by accident.
That escape hatch is what makes a strict rule tenable — a validator permissive enough to allow COUNT(*) + ? safely is a parser pretending to be a regex.
Breakage
None in the existing suite: 2471 pass. I expected select to be the risky one and measured instead that nothing legitimate was relying on the gap — expression strings were already going through fragments.
Verification
- Live Postgres: the original exploit now throws before the driver is reached; the table survives
- Full surface map re-run:
0/14injectable, down from8/14 - 11 new regression tests, mutation-checked — disabling the validator fails 8 of them
tscandpickierclean
Two full-suite runs each showed a different test timing out at ~5000ms (cli, migration locking); both pass in isolation on this branch and on clean main, so they are load flakes rather than regressions.
Not covered
having and the window-function partitionBy/orderBy did not reproduce through my probe, so I have not claimed or changed them. buildOverClause does interpolate its columns unquoted and is worth a second look — I would rather raise that separately than fold an unverified change into a security fix.
🤖 Generated with Claude Code