ReviewOS

also looking at this

stacks/bun-query-builder

fix(security): validate column references in window functions

#1186
Merged glennmichael123 wants to merge fix/window-fn-injection into main
2 files +127 -13
packages/bun-query-builder/test/client.window-fn-injection.test.tsadded+86-0
Changes to packages/bun-query-builder/test/client.window-fn-injection.test.ts
@@ -0,0 +1,86 @@
1/**
2 * Window-function identifier injection — stacksjs/bun-query-builder#1185.
3 *
4 * #1183 validated the SELECT / FROM / ORDER BY / GROUP BY family and did not
5 * reach `buildOverClause` or `addWindowFunction`, which interpolated all four
6 * of their inputs raw. Verified against live Postgres before the fix:
7 *
8 * sumOver('amount', { partitionBy: 'team_id) AS x, (SELECT secret FROM t LIMIT 1' })
9 * -> {"amount_sum": "hunter2"}
10 *
11 * The payload closes the PARTITION BY parenthesis and opens a subquery, so it
12 * exfiltrates without a stacked statement and works wherever the driver
13 * refuses multiple statements.
14 */
15
16import { describe, expect, it } from 'bun:test'
17import { buildDatabaseSchema, buildSchemaMeta, createQueryBuilder } from '../src'
18
19function qb(): any {
20 const models = {
21 users: {
22 columns: {
23 id: { type: 'integer', isPrimaryKey: true },
24 team_id: { type: 'integer' },
25 amount: { type: 'integer' },
26 },
27 },
28 } as any
29 return createQueryBuilder<any>({
30 schema: buildDatabaseSchema(models),
31 meta: buildSchemaMeta(models),
32 autoMigration: { enabled: false } as any,
33 })
34}
35
36const EXFIL = 'team_id) AS x, (SELECT secret FROM users LIMIT 1'
37const STACKED = 'id; DROP TABLE users; --'
38const REFUSED = /is not a plain column reference/
39
40describe('window functions refuse injected identifiers (#1185)', () => {
41 it('partitionBy, the vector proven against a live database', () => {
42 expect(() => qb().selectFrom('users').sumOver('amount', { partitionBy: EXFIL })).toThrow(REFUSED)
43 expect(() => qb().selectFrom('users').lag('amount', { partitionBy: EXFIL })).toThrow(REFUSED)
44 })
45
46 it('orderBy, column and alias are the other three', () => {
47 expect(() => qb().selectFrom('users').sumOver('amount', { orderBy: [[STACKED, 'asc']] })).toThrow(REFUSED)
48 expect(() => qb().selectFrom('users').sumOver(STACKED, {})).toThrow(REFUSED)
49 expect(() => qb().selectFrom('users').sumOver('amount', { alias: STACKED })).toThrow(REFUSED)
50 })
51
52 it('every convenience method, not just sumOver', () => {
53 const each = (fn: string) => () => (qb().selectFrom('users') as any)[fn]('amount', { partitionBy: EXFIL })
54 for (const fn of ['avgOver', 'countOver', 'minOver', 'maxOver', 'firstValue', 'lastValue', 'lead'])
55 expect(each(fn)).toThrow(REFUSED)
56 })
57
58 it('COUNT(*) is still allowed, and only for COUNT', () => {
59 // The canonical window count. SUM(*) and AVG(*) are not valid SQL, so the
60 // star is permitted here and refused everywhere else - caught by the
61 // existing #1050 suite when the first pass of this fix rejected it.
62 expect(String(qb().selectFrom('users').countOver('*').toSQL())).toContain('COUNT(*)')
63 expect(() => qb().selectFrom('users').sumOver('*', {})).toThrow(REFUSED)
64 })
65
66 it('over() still validates partitionBy, though its expression stays raw', () => {
67 // The expression is the documented escape hatch, same contract as `raw`.
68 // partitionBy is not, and was injectable through it too.
69 expect(() => qb().selectFrom('users').over('ROW_NUMBER()', 'rn', { partitionBy: EXFIL })).toThrow(REFUSED)
70 })
71})
72
73describe('legitimate window usage is unaffected (#1185)', () => {
74 it('builds the clause it always did', () => {
75 const sum = String(qb().selectFrom('users').sumOver('amount', { partitionBy: 'team_id', orderBy: [['id', 'desc']] }).toSQL())
76 expect(sum).toContain('SUM(amount) OVER (PARTITION BY team_id ORDER BY id DESC)')
77
78 // An array partitionBy, and an offset that is a number rather than a column.
79 const lag = String(qb().selectFrom('users').lag('amount', { offset: 2, partitionBy: ['team_id', 'id'] }).toSQL())
80 expect(lag).toContain('LAG(amount, 2) OVER (PARTITION BY team_id, id)')
81
82 // over()'s expression is not a column reference and must stay raw.
83 const over = String(qb().selectFrom('users').over('ROW_NUMBER()', 'rn', { partitionBy: 'team_id' }).toSQL())
84 expect(over).toContain('ROW_NUMBER() OVER (PARTITION BY team_id) AS rn')
85 })
86})