From 0dda114405abf641ea15157552c0b41f281ad277 Mon Sep 17 00:00:00 2001 From: kevin-dp Date: Wed, 17 Jun 2026 16:26:27 +0200 Subject: [PATCH 1/6] test: reproduce prototype pollution via select() alias (#1584) Adds a failing test demonstrating that .select() alias paths like `__proto__.polluted` or `constructor.prototype.polluted` are split on '.' and walked into the result object without sanitization, allowing prototype pollution through queryOnce(). This commit intentionally fails CI to demonstrate the vulnerability; the next commit fixes it. --- .../query/select-prototype-pollution.test.ts | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 packages/db/tests/query/select-prototype-pollution.test.ts diff --git a/packages/db/tests/query/select-prototype-pollution.test.ts b/packages/db/tests/query/select-prototype-pollution.test.ts new file mode 100644 index 000000000..34d4ff88e --- /dev/null +++ b/packages/db/tests/query/select-prototype-pollution.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from 'vitest' +import { createCollection } from '../../src/collection/index.js' +import { queryOnce } from '../../src/query/index.js' +import { mockSyncCollectionOptions } from '../utils.js' + +type User = { id: number; name: string } + +const sampleUsers: Array = [ + { id: 1, name: `Alice` }, + { id: 2, name: `Bob` }, +] + +function makeCollection() { + return createCollection( + mockSyncCollectionOptions({ + id: `proto-pollution-users`, + getKey: (u) => u.id, + initialData: sampleUsers, + }), + ) +} + +describe(`select() alias prototype pollution (issue #1584)`, () => { + it(`should not allow __proto__ in alias path to pollute Object.prototype`, async () => { + const users = makeCollection() + const before = ({} as any).polluted + + await expect( + queryOnce((q) => + q.from({ user: users }).select(({ user }) => ({ + [`__proto__.polluted`]: user.name, + })), + ), + ).rejects.toThrow() + + const after = ({} as any).polluted + expect(after).toBe(before) + expect(({} as any).polluted).toBeUndefined() + }) + + it(`should reject constructor in alias path`, async () => { + const users = makeCollection() + await expect( + queryOnce((q) => + q.from({ user: users }).select(({ user }) => ({ + [`constructor.prototype.polluted`]: user.name, + })), + ), + ).rejects.toThrow() + expect(({} as any).polluted).toBeUndefined() + }) +}) From 8bfe0be872fb52024299a2d19745001eccfcc0de Mon Sep 17 00:00:00 2001 From: kevin-dp Date: Wed, 17 Jun 2026 16:39:33 +0200 Subject: [PATCH 2/6] fix(db): reject unsafe alias path segments in select() compiler Adds a new `UnsafeAliasPathError` (extends QueryCompilationError) and an `assertSafeAliasSegments` helper invoked in three places in packages/db/src/query/compiler/select.ts: - `addFromObject` validates each non-spread key at compile time, including dotted keys, before recording any select operation. - `processNonMergeOp` validates the split alias path before walking into the result object. - `processMerge` validates `targetPath` for the same reason. Segments matching `__proto__`, `prototype`, or `constructor` are rejected, which prevents prototype pollution via aliases like `__proto__.polluted` or `constructor.prototype.polluted` going through queryOnce() / createLiveQueryCollection(). Fixes #1584 --- packages/db/src/errors.ts | 10 ++++++++++ packages/db/src/query/compiler/select.ts | 18 +++++++++++++++++- 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/packages/db/src/errors.ts b/packages/db/src/errors.ts index 472fcabd1..0bfd2f996 100644 --- a/packages/db/src/errors.ts +++ b/packages/db/src/errors.ts @@ -442,6 +442,16 @@ export class QueryCompilationError extends TanStackDBError { } } +export class UnsafeAliasPathError extends QueryCompilationError { + constructor(segment: string) { + super( + `Unsafe alias path segment "${segment}" is not allowed in .select(). ` + + `Aliases must not contain "__proto__", "prototype", or "constructor".`, + ) + this.name = `UnsafeAliasPathError` + } +} + export class DistinctRequiresSelectError extends QueryCompilationError { constructor() { super(`DISTINCT requires a SELECT clause.`) diff --git a/packages/db/src/query/compiler/select.ts b/packages/db/src/query/compiler/select.ts index e257b09ca..4d6dcab63 100644 --- a/packages/db/src/query/compiler/select.ts +++ b/packages/db/src/query/compiler/select.ts @@ -5,7 +5,7 @@ import { Value as ValClass, isExpressionLike, } from '../ir.js' -import { AggregateNotSupportedError } from '../../errors.js' +import { AggregateNotSupportedError, UnsafeAliasPathError } from '../../errors.js' import { compileExpression, isCaseWhenConditionTrue } from './evaluators.js' import { containsAggregate } from './group-by.js' import type { @@ -39,6 +39,17 @@ function unwrapVal(input: any): any { return input } + +const UNSAFE_ALIAS_SEGMENTS = new Set([`__proto__`, `prototype`, `constructor`]) + +function assertSafeAliasSegments(segments: ReadonlyArray): void { + for (const seg of segments) { + if (UNSAFE_ALIAS_SEGMENTS.has(seg)) { + throw new UnsafeAliasPathError(seg) + } + } +} + /** * Processes a merge operation by merging source values into the target path */ @@ -47,6 +58,7 @@ function processMerge( namespacedRow: NamespacedRow, selectResults: Record, ): void { + assertSafeAliasSegments(op.targetPath) const value = op.source(namespacedRow) if (value && typeof value === `object`) { // Ensure target object exists @@ -89,6 +101,7 @@ function processNonMergeOp( ): void { // Support nested alias paths like "meta.author.name" const path = op.alias.split(`.`) + assertSafeAliasSegments(path) if (path.length === 1) { selectResults[op.alias] = op.compiled(namespacedRow) } else { @@ -283,6 +296,9 @@ function addFromObject( ops: Array, ) { for (const [key, value] of Object.entries(obj)) { + if (!key.startsWith(`__SPREAD_SENTINEL__`)) { + assertSafeAliasSegments(key.split(`.`)) + } if (key.startsWith(`__SPREAD_SENTINEL__`)) { const rest = key.slice(`__SPREAD_SENTINEL__`.length) const splitIndex = rest.lastIndexOf(`__`) From ad935098c0125ae81b3a00298e24d5fb2cbed363 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Wed, 17 Jun 2026 14:40:57 +0000 Subject: [PATCH 3/6] ci: apply automated fixes --- packages/db/src/query/compiler/select.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/db/src/query/compiler/select.ts b/packages/db/src/query/compiler/select.ts index 4d6dcab63..88ec84291 100644 --- a/packages/db/src/query/compiler/select.ts +++ b/packages/db/src/query/compiler/select.ts @@ -5,7 +5,10 @@ import { Value as ValClass, isExpressionLike, } from '../ir.js' -import { AggregateNotSupportedError, UnsafeAliasPathError } from '../../errors.js' +import { + AggregateNotSupportedError, + UnsafeAliasPathError, +} from '../../errors.js' import { compileExpression, isCaseWhenConditionTrue } from './evaluators.js' import { containsAggregate } from './group-by.js' import type { @@ -39,7 +42,6 @@ function unwrapVal(input: any): any { return input } - const UNSAFE_ALIAS_SEGMENTS = new Set([`__proto__`, `prototype`, `constructor`]) function assertSafeAliasSegments(segments: ReadonlyArray): void { From 0d6fba394b60ee5236d9f39983d8a4c3b4f43d35 Mon Sep 17 00:00:00 2001 From: kevin-dp Date: Wed, 17 Jun 2026 16:52:18 +0200 Subject: [PATCH 4/6] test: address CodeRabbit review on prototype-pollution tests - Import UnsafeAliasPathError and assert that the rejection is exactly that error class instead of a permissive .rejects.toThrow(). - Drop the `({} as any).polluted` pattern in favour of Object.prototype.hasOwnProperty.call(Object.prototype, 'polluted'), which is type-safe and a more explicit assertion that Object.prototype itself was not mutated. --- .../query/select-prototype-pollution.test.ts | 26 ++++++++++++------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/packages/db/tests/query/select-prototype-pollution.test.ts b/packages/db/tests/query/select-prototype-pollution.test.ts index 34d4ff88e..d8ca518b5 100644 --- a/packages/db/tests/query/select-prototype-pollution.test.ts +++ b/packages/db/tests/query/select-prototype-pollution.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import { createCollection } from '../../src/collection/index.js' import { queryOnce } from '../../src/query/index.js' +import { UnsafeAliasPathError } from '../../src/errors.js' import { mockSyncCollectionOptions } from '../utils.js' type User = { id: number; name: string } @@ -20,10 +21,14 @@ function makeCollection() { ) } +function prototypeHasOwn(prop: string): boolean { + return Object.prototype.hasOwnProperty.call(Object.prototype, prop) +} + describe(`select() alias prototype pollution (issue #1584)`, () => { - it(`should not allow __proto__ in alias path to pollute Object.prototype`, async () => { + it(`should reject __proto__ in alias path and not pollute Object.prototype`, async () => { const users = makeCollection() - const before = ({} as any).polluted + const hadBefore = prototypeHasOwn(`polluted`) await expect( queryOnce((q) => @@ -31,22 +36,25 @@ describe(`select() alias prototype pollution (issue #1584)`, () => { [`__proto__.polluted`]: user.name, })), ), - ).rejects.toThrow() + ).rejects.toThrow(UnsafeAliasPathError) - const after = ({} as any).polluted - expect(after).toBe(before) - expect(({} as any).polluted).toBeUndefined() + expect(prototypeHasOwn(`polluted`)).toBe(hadBefore) + expect(prototypeHasOwn(`polluted`)).toBe(false) }) - it(`should reject constructor in alias path`, async () => { + it(`should reject constructor in alias path and not pollute Object.prototype`, async () => { const users = makeCollection() + const hadBefore = prototypeHasOwn(`polluted`) + await expect( queryOnce((q) => q.from({ user: users }).select(({ user }) => ({ [`constructor.prototype.polluted`]: user.name, })), ), - ).rejects.toThrow() - expect(({} as any).polluted).toBeUndefined() + ).rejects.toThrow(UnsafeAliasPathError) + + expect(prototypeHasOwn(`polluted`)).toBe(hadBefore) + expect(prototypeHasOwn(`polluted`)).toBe(false) }) }) From 6cc63e3d6a97768ab2cbab4465cd8d09c58fb5a4 Mon Sep 17 00:00:00 2001 From: kevin-dp Date: Wed, 17 Jun 2026 17:03:28 +0200 Subject: [PATCH 5/6] chore: add changeset for #1584 fix --- .changeset/select-alias-prototype-pollution.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/select-alias-prototype-pollution.md diff --git a/.changeset/select-alias-prototype-pollution.md b/.changeset/select-alias-prototype-pollution.md new file mode 100644 index 000000000..8ff32c710 --- /dev/null +++ b/.changeset/select-alias-prototype-pollution.md @@ -0,0 +1,5 @@ +--- +"@tanstack/db": patch +--- + +Fix prototype pollution via `select()` alias paths. Aliases were split on `.` and walked into the result object without sanitization, so a query like `select(() => ({ ['__proto__.polluted']: ... }))` (or any segment matching `__proto__`, `prototype`, or `constructor`) could mutate `Object.prototype`. The select compiler now rejects unsafe alias path segments with a new `UnsafeAliasPathError`. Fixes #1584. From 5f3fe93c442e0ceb1e63b52e385579d60dedd2c4 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Wed, 17 Jun 2026 15:04:52 +0000 Subject: [PATCH 6/6] ci: apply automated fixes --- .changeset/select-alias-prototype-pollution.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/select-alias-prototype-pollution.md b/.changeset/select-alias-prototype-pollution.md index 8ff32c710..e1205ef23 100644 --- a/.changeset/select-alias-prototype-pollution.md +++ b/.changeset/select-alias-prototype-pollution.md @@ -1,5 +1,5 @@ --- -"@tanstack/db": patch +'@tanstack/db': patch --- Fix prototype pollution via `select()` alias paths. Aliases were split on `.` and walked into the result object without sanitization, so a query like `select(() => ({ ['__proto__.polluted']: ... }))` (or any segment matching `__proto__`, `prototype`, or `constructor`) could mutate `Object.prototype`. The select compiler now rejects unsafe alias path segments with a new `UnsafeAliasPathError`. Fixes #1584.