ReviewOS

also looking at this

stacks/bun-query-builder

fix(security): validate column references on the SELECT/ORDER BY family

#1183
Merged glennmichael123 wants to merge fix/identifier-injection into main
2 files +182 -10
8 days ago

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  : false

One 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.

beforeafter
orderBy / orderByDesc / latest / oldestinjectedthrows
groupBy / distinctOninjectedthrows
select / addSelectinjectedthrows
selectFrominjectedthrows
injectable surfaces: 0/14

One 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/14 injectable, down from 8/14
  • 11 new regression tests, mutation-checked — disabling the validator fails 8 of them
  • tsc and pickier clean

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

2 changed files on the files tab, with 0 review threads.

Sign in to comment on this pull request.