also looking at this
fix(security): validate column references in window functions
#1186
2 files
+127
-13
| @@ -759,13 +759,31 @@ function formatSubqueryValue(val: unknown): string { | ||
| 759 | 759 | |
| 760 | 760 | // Shared OVER (...) builder for the window functions (rowNumber/rank/... and |
| 761 | 761 | // lag/lead/sumOver/...). Stateless — see the note above on module-level helpers. |
| 762 | /** | |
| 763 | * The `OVER (...)` clause, with every column reference validated. | |
| 764 | * | |
| 765 | * Both halves used to be interpolated raw, so a `partitionBy` taken from | |
| 766 | * request input closed the parenthesis and opened a subquery: | |
| 767 | * | |
| 768 | * sumOver('amount', { partitionBy: 'team_id) AS x, (SELECT secret FROM t LIMIT 1' }) | |
| 769 | * -> {"amount_sum": "hunter2"} | |
| 770 | * | |
| 771 | * No stacked statement needed, so it worked wherever the driver refuses | |
| 772 | * multiple statements. #1183 fixed the SELECT/ORDER BY family and did not | |
| 773 | * reach here. stacksjs/bun-query-builder#1185 | |
| 774 | * | |
| 775 | * The direction is mapped through a ternary and cannot carry a payload. | |
| 776 | */ | |
| 762 | 777 | function buildOverClause(partitionBy?: string | string[], orderBy?: [string, 'asc' | 'desc'][]): string { |
| 763 | 778 | const cols = Array.isArray(partitionBy) ? partitionBy : (partitionBy ? [partitionBy] : []) |
| 764 | 779 | const parts: string[] = [] |
| 765 | 780 | if (cols.length) |
| 766 | parts.push(`PARTITION BY ${cols.join(', ')}`) | |
| 767 | if (orderBy && orderBy.length) | |
| 768 | parts.push(`ORDER BY ${orderBy.map(([c, d]) => `${c} ${d === 'desc' ? 'DESC' : 'ASC'}`).join(', ')}`) | |
| 781 | parts.push(`PARTITION BY ${cols.map(one => assertSafeColumnRef(one, 'over(partitionBy)')).join(', ')}`) | |
| 782 | if (orderBy && orderBy.length) { | |
| 783 | parts.push(`ORDER BY ${orderBy | |
| 784 | .map(([c, d]) => `${assertSafeColumnRef(c, 'over(orderBy)')} ${d === 'desc' ? 'DESC' : 'ASC'}`) | |
| 785 | .join(', ')}`) | |
| 786 | } | |
| 769 | 787 | return parts.length ? `OVER (${parts.join(' ')})` : 'OVER ()' |
| 770 | 788 | } |
| 771 | 789 | |
| @@ -4292,7 +4310,11 @@ export function createQueryBuilder<DB extends AnyDatabaseSchema>(state?: Partial | ||
| 4292 | 4310 | } |
| 4293 | 4311 | |
| 4294 | 4312 | const addWindowFunction = (fnExpr: string, alias: string, partitionBy?: string | string[], orderBy?: [string, 'asc' | 'desc'][]): void => { |
| 4295 | addToSelectClause(`${fnExpr} ${buildOverClause(partitionBy, orderBy)} AS ${alias}`) | |
| 4313 | // `fnExpr` is built by the caller and may legitimately be an expression | |
| 4314 | // (`SUM(amount)`, and `over()`'s raw escape hatch). The alias cannot: | |
| 4315 | // it was interpolated after `AS` and took a payload the same way. #1185 | |
| 4316 | const safeAlias = assertSafeColumnRef(alias, 'over(alias)') | |
| 4317 | addToSelectClause(`${fnExpr} ${buildOverClause(partitionBy, orderBy)} AS ${safeAlias}`) | |
| 4296 | 4318 | } |
| 4297 | 4319 | |
| 4298 | 4320 | // Helper function to build hasOne/hasMany subquery with validation |
| @@ -4588,45 +4610,51 @@ export function createQueryBuilder<DB extends AnyDatabaseSchema>(state?: Partial | ||
| 4588 | 4610 | return this as any |
| 4589 | 4611 | }, |
| 4590 | 4612 | lag(column: string, opts: WindowOpts & { offset?: number, defaultValue?: string | number } = {}) { |
| 4591 | const args = [column, String(opts.offset ?? 1)] | |
| 4613 | // Only the first arg is a column; the offset is stringified from a | |
| 4614 | // number and the default value is a caller-supplied literal. #1185 | |
| 4615 | const args = [assertSafeColumnRef(column, 'over(column)'), String(opts.offset ?? 1)] | |
| 4592 | 4616 | if (opts.defaultValue !== undefined) |
| 4593 | 4617 | args.push(String(opts.defaultValue)) |
| 4594 | 4618 | addWindowFunction(`LAG(${args.join(', ')})`, opts.alias ?? `${column}_lag`, opts.partitionBy, opts.orderBy) |
| 4595 | 4619 | return this as any |
| 4596 | 4620 | }, |
| 4597 | 4621 | lead(column: string, opts: WindowOpts & { offset?: number, defaultValue?: string | number } = {}) { |
| 4598 | const args = [column, String(opts.offset ?? 1)] | |
| 4622 | // Only the first arg is a column; the offset is stringified from a | |
| 4623 | // number and the default value is a caller-supplied literal. #1185 | |
| 4624 | const args = [assertSafeColumnRef(column, 'over(column)'), String(opts.offset ?? 1)] | |
| 4599 | 4625 | if (opts.defaultValue !== undefined) |
| 4600 | 4626 | args.push(String(opts.defaultValue)) |
| 4601 | 4627 | addWindowFunction(`LEAD(${args.join(', ')})`, opts.alias ?? `${column}_lead`, opts.partitionBy, opts.orderBy) |
| 4602 | 4628 | return this as any |
| 4603 | 4629 | }, |
| 4604 | 4630 | sumOver(column: string, opts: WindowOpts = {}) { |
| 4605 | addWindowFunction(`SUM(${column})`, opts.alias ?? `${column}_sum`, opts.partitionBy, opts.orderBy) | |
| 4631 | addWindowFunction(`SUM(${assertSafeColumnRef(column, 'over(column)')})`, opts.alias ?? `${column}_sum`, opts.partitionBy, opts.orderBy) | |
| 4606 | 4632 | return this as any |
| 4607 | 4633 | }, |
| 4608 | 4634 | avgOver(column: string, opts: WindowOpts = {}) { |
| 4609 | addWindowFunction(`AVG(${column})`, opts.alias ?? `${column}_avg`, opts.partitionBy, opts.orderBy) | |
| 4635 | addWindowFunction(`AVG(${assertSafeColumnRef(column, 'over(column)')})`, opts.alias ?? `${column}_avg`, opts.partitionBy, opts.orderBy) | |
| 4610 | 4636 | return this as any |
| 4611 | 4637 | }, |
| 4638 | // `COUNT(*)` is the canonical window count, so the star is allowed here | |
| 4639 | // and nowhere else: `SUM(*)` and `AVG(*)` are not valid SQL. #1185 | |
| 4612 | 4640 | countOver(column: string = '*', opts: WindowOpts = {}) { |
| 4613 | addWindowFunction(`COUNT(${column})`, opts.alias ?? 'count_over', opts.partitionBy, opts.orderBy) | |
| 4641 | addWindowFunction(`COUNT(${assertSafeColumnRef(column, 'over(column)', { allowStar: true })})`, opts.alias ?? 'count_over', opts.partitionBy, opts.orderBy) | |
| 4614 | 4642 | return this as any |
| 4615 | 4643 | }, |
| 4616 | 4644 | minOver(column: string, opts: WindowOpts = {}) { |
| 4617 | addWindowFunction(`MIN(${column})`, opts.alias ?? `${column}_min`, opts.partitionBy, opts.orderBy) | |
| 4645 | addWindowFunction(`MIN(${assertSafeColumnRef(column, 'over(column)')})`, opts.alias ?? `${column}_min`, opts.partitionBy, opts.orderBy) | |
| 4618 | 4646 | return this as any |
| 4619 | 4647 | }, |
| 4620 | 4648 | maxOver(column: string, opts: WindowOpts = {}) { |
| 4621 | addWindowFunction(`MAX(${column})`, opts.alias ?? `${column}_max`, opts.partitionBy, opts.orderBy) | |
| 4649 | addWindowFunction(`MAX(${assertSafeColumnRef(column, 'over(column)')})`, opts.alias ?? `${column}_max`, opts.partitionBy, opts.orderBy) | |
| 4622 | 4650 | return this as any |
| 4623 | 4651 | }, |
| 4624 | 4652 | firstValue(column: string, opts: WindowOpts = {}) { |
| 4625 | addWindowFunction(`FIRST_VALUE(${column})`, opts.alias ?? `${column}_first`, opts.partitionBy, opts.orderBy) | |
| 4653 | addWindowFunction(`FIRST_VALUE(${assertSafeColumnRef(column, 'over(column)')})`, opts.alias ?? `${column}_first`, opts.partitionBy, opts.orderBy) | |
| 4626 | 4654 | return this as any |
| 4627 | 4655 | }, |
| 4628 | 4656 | lastValue(column: string, opts: WindowOpts = {}) { |
| 4629 | addWindowFunction(`LAST_VALUE(${column})`, opts.alias ?? `${column}_last`, opts.partitionBy, opts.orderBy) | |
| 4657 | addWindowFunction(`LAST_VALUE(${assertSafeColumnRef(column, 'over(column)')})`, opts.alias ?? `${column}_last`, opts.partitionBy, opts.orderBy) | |
| 4630 | 4658 | return this as any |
| 4631 | 4659 | }, |
| 4632 | 4660 | selectAll(tables?: string | string[]) { |
| @@ -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 | }) | |