diff --git a/.changeset/select-alias-prototype-pollution.md b/.changeset/select-alias-prototype-pollution.md new file mode 100644 index 000000000..e1205ef23 --- /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. 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..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 } 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 +42,16 @@ 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 +60,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 +103,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 +298,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(`__`) 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..d8ca518b5 --- /dev/null +++ b/packages/db/tests/query/select-prototype-pollution.test.ts @@ -0,0 +1,60 @@ +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 } + +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, + }), + ) +} + +function prototypeHasOwn(prop: string): boolean { + return Object.prototype.hasOwnProperty.call(Object.prototype, prop) +} + +describe(`select() alias prototype pollution (issue #1584)`, () => { + it(`should reject __proto__ 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 }) => ({ + [`__proto__.polluted`]: user.name, + })), + ), + ).rejects.toThrow(UnsafeAliasPathError) + + expect(prototypeHasOwn(`polluted`)).toBe(hadBefore) + expect(prototypeHasOwn(`polluted`)).toBe(false) + }) + + 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(UnsafeAliasPathError) + + expect(prototypeHasOwn(`polluted`)).toBe(hadBefore) + expect(prototypeHasOwn(`polluted`)).toBe(false) + }) +})