Closes #1185. The loose end from the #1181 audit — and it was real.
buildOverClause and addWindowFunction interpolated all four of their inputs raw. #1183 validated the SELECT / FROM / ORDER BY / GROUP BY family and did not reach here.
Proof, live Postgres
const payload = 'team_id) AS x, (SELECT secret FROM win_users LIMIT 1'
await db.selectFrom('win_users').sumOver('amount', { partitionBy: payload }).get()EXFILTRATED: {"id":1,"team_id":1,"amount":10,"secret":"hunter2","x":"30","amount_sum":"hunter2"}amount_sum is the secret. The payload closes the PARTITION BY ( and opens a subquery, so no stacked statement is needed — this works wherever the driver refuses multiple statements, unlike the #1183 case.
Four vectors
| input | lands in | before | after |
|---|---|---|---|
partitionBy | OVER (PARTITION BY …) | injected | throws |
orderBy | OVER (ORDER BY …) | injected | throws |
column | SUM(…) | injected | throws |
alias | … AS … | injected | throws |
All reachable through all nine public methods: sumOver, avgOver, countOver, minOver, maxOver, firstValue, lastValue, lag, lead.
Deliberately left raw
over()'sexpressionis the documented escape hatch, same contract asraw. ItspartitionByis not, and was injectable through it too — that one is now validated.COUNT(*)keeps its star. It is the canonical window count, whileSUM(*)andAVG(*)are not valid SQL, so the allowance is exactly that narrow and nothing else gets it.
How I missed this the first time
In the #1181 audit I probed rowNumber({ partitionBy }), which is not the signature. It returned a no-op, I read that as "not reachable", and filed it as an unconfirmed lead rather than checking the real API. The actual shape is sumOver(column, opts).
"My probe did not reach it" was never evidence that nothing does, and I should not have let it stand as a reason to defer.
One real breakage, caught by the existing suite
The first pass rejected countOver('*') and the #1050 window-function tests went red. That is a legitimate call, so the fix was wrong rather than the test — COUNT(*) now has its own allowance and its own regression test.
Verification
- Live exfiltration now throws before reaching the driver
- Full probe: all four vectors refused across all nine methods
- Legitimate usage unchanged —
SUM(amount) OVER (PARTITION BY team_id ORDER BY id DESC), arraypartitionBy, numericlagoffset, andover()'s raw expression all build as before - 6 new tests, mutation-checked — disabling the validator fails 4 of them
- Full suite 2485 pass, 0 fail;
tscandpickierclean
🤖 Generated with Claude Code