also looking at this
fix(security): validate column references in window functions
#1186
2 files
+127
-13
| @@ -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 | ||
| 16 | import { describe, expect, it } from 'bun:test' | |
| 17 | import { buildDatabaseSchema, buildSchemaMeta, createQueryBuilder } from '../src' | |
| 18 | ||
| 19 | function 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 | ||
| 36 | const EXFIL = 'team_id) AS x, (SELECT secret FROM users LIMIT 1' | |
| 37 | const STACKED = 'id; DROP TABLE users; --' | |
| 38 | const REFUSED = /is not a plain column reference/ | |
| 39 | ||
| 40 | describe('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 | ||
| 73 | describe('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 | }) | |