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[]) { |