From 80966172f122b8b2639ee4e39b8523f0e306773c Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 13 Sep 2026 13:24:18 +0000 Subject: [PATCH] fix(core): guard filter-token near-miss lookup against Object.prototype resolveContextTokens indexed the spec's near-miss suggestion map with a plain bracket index, so a lower-cased token that happens to be an inherited Object.prototype member name (constructor, __proto__) resolved to that member instead of undefined, and the warning asserted a "suggestion" that was native-code / object text rather than a real token. The lookup is read-only and the resolved value is always passed through untouched, so no filter widens or narrows and no record is mis-matched by this -- the only observable effect was a confusing console.warn string. Fix: build the lookup over a null-prototype copy of the suggestion map instead of special-casing the two known spellings, closing the whole class of collisions (any inherited member, present or future). Pin asserts silence for `constructor` / `__proto__` plus a lit near-miss control, beside the existing near-miss cases. Part of #9129 Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_011QreXiyMEqKLN4U5daMPVa --- ...129-filter-token-suggestion-proto-guard.md | 21 ++++++ .../filter-token-proto-guard-9129.test.ts | 68 +++++++++++++++++++ packages/core/src/utils/filter-tokens.ts | 25 ++++++- 3 files changed, 113 insertions(+), 1 deletion(-) create mode 100644 .changeset/9129-filter-token-suggestion-proto-guard.md create mode 100644 packages/core/src/utils/__tests__/filter-token-proto-guard-9129.test.ts diff --git a/.changeset/9129-filter-token-suggestion-proto-guard.md b/.changeset/9129-filter-token-suggestion-proto-guard.md new file mode 100644 index 0000000000..5b0be8f516 --- /dev/null +++ b/.changeset/9129-filter-token-suggestion-proto-guard.md @@ -0,0 +1,21 @@ +--- +'@object-ui/core': patch +--- + +Fix the filter-token near-miss suggestion lookup reading `Object.prototype` (objectui#9129). + +`resolveContextTokens` looked a near-miss spelling up in the spec's suggestion map with a +plain bracket index. Two lower-cased spellings, `{constructor}` and `{__proto__}`, are +inherited `Object.prototype` member names, so the lookup resolved to +`Object.prototype.constructor` / `Object.prototype.__proto__` instead of `undefined`, and +the console warning asserted a "suggestion" that was actually native-code / object text — +not a real token, not spellable, and not anything an author could act on. + +This is **not** prototype pollution: the index was always a read, never an assignment, and +the resolved filter value is passed through untouched either way — no filter is ever +widened or narrowed and no record is ever mis-matched by it. The only observable effect was +a confusing string inside a `console.warn` call. + +The fix builds the lookup over a null-prototype copy of the suggestion map instead of +special-casing the two names, so the whole class of collisions is closed (any inherited +member, present or future), not just today's two spellings. diff --git a/packages/core/src/utils/__tests__/filter-token-proto-guard-9129.test.ts b/packages/core/src/utils/__tests__/filter-token-proto-guard-9129.test.ts new file mode 100644 index 0000000000..722b1190a5 --- /dev/null +++ b/packages/core/src/utils/__tests__/filter-token-proto-guard-9129.test.ts @@ -0,0 +1,68 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { describe, expect, it, vi } from 'vitest'; +import { resolveContextTokens } from '../filter-tokens'; + +/** + * objectui#9129 — the near-miss suggestion lookup must never read + * `Object.prototype`. + * + * `resolveContextTokens` looks a near-miss spelling up in + * `CONTEXT_TOKEN_SUGGESTIONS` (a plain object) with a bracket index. `{ + * constructor}` and `{__proto__}` lower-case to inherited `Object.prototype` + * member names, so the index resolved to `Object.prototype.constructor` / + * `Object.prototype.__proto__` instead of `undefined`, and the resulting + * warning asserted a "suggestion" that was actually native-code / object + * text — not a token, not spellable, not anything an author can act on. + * + * This is NOT prototype pollution: the index is a read, never an assignment; + * the resolved value is passed through untouched either way, so no filter is + * ever widened or narrowed and no record is ever mis-matched. The only + * observable effect was a confusing string inside a `console.warn` call. + * + * ## What this pins, and why not today's strings + * + * The fix closes the lookup against the WHOLE prototype-chain class (any + * inherited member, present or future), not just these two spellings — so + * the pin asserts SILENCE (no warning, value passed through), never today's + * `[native code]` / `[object Object]` text. Pinning those strings would only + * prove today's two names are special-cased; it would say nothing about the + * next inherited member that happens to already be lower-case. + */ +describe('objectui#9129 — near-miss lookup does not read Object.prototype', () => { + const SCOPE = { currentUserId: 'usr_42', currentOrgId: 'org_7' }; + + it.each(['constructor', '__proto__'])( + 'passes "{%s}" through silently instead of warning with a prototype value', + (token) => { + const warn = vi.fn(); + const out = resolveContextTokens({ f: `{${token}}` }, { ...SCOPE, onUnresolved: warn }); + + // Never substituted — it is not a recognised context token either way. + expect(out).toEqual({ f: `{${token}}` }); + // The load-bearing assertion: no warning at all, not merely a + // different one. A `[native code]` / `[object Object]` string in the + // warning is exactly the defect; silence is the only correct outcome + // for a spelling that names nothing in the suggestion map. + expect(warn).not.toHaveBeenCalled(); + }, + ); + + it('still warns with a real suggestion — the guard must not silence genuine near-misses', () => { + // Lit control: `user_id` is a genuine near-miss (own-property entry in + // CONTEXT_TOKEN_SUGGESTIONS), proving the suggestion feature itself + // still works and the fix did not just make the resolver quiet. + const warn = vi.fn(); + const out = resolveContextTokens({ f: '{user_id}' }, { ...SCOPE, onUnresolved: warn }); + + expect(out).toEqual({ f: '{user_id}' }); + expect(warn).toHaveBeenCalledTimes(1); + expect(warn.mock.calls[0][0]).toContain('did you mean "{current_user_id}"?'); + }); +}); diff --git a/packages/core/src/utils/filter-tokens.ts b/packages/core/src/utils/filter-tokens.ts index f101d80df9..4d05d23e16 100644 --- a/packages/core/src/utils/filter-tokens.ts +++ b/packages/core/src/utils/filter-tokens.ts @@ -99,6 +99,29 @@ export interface FilterTokenScope { /** Whole-string placeholder: `{token}` or `${token}`, anchored. */ const WHOLE_TOKEN_RE = /^\$?\{([a-zA-Z0-9_]+)\}$/; +/** + * A null-prototype copy of the spec's near-miss suggestion map (objectui#9129). + * + * `CONTEXT_TOKEN_SUGGESTIONS` is a plain object, so indexing it with an + * author-controlled, lower-cased string reaches `Object.prototype` for any + * spelling that happens to be an inherited member name — `constructor` and + * `__proto__` today, and, silently, whichever future member is added in + * lower case (see the lookup below). This is a read-only console hint, never + * an assignment, so no filter value is ever widened, narrowed or mismatched + * by it; the only externally visible effect of the underlying bug is a + * confusing suggestion string in a `warn()` call. + * + * Copying into an `Object.create(null)` base closes the whole class rather + * than special-casing the two spellings measured today: this object has no + * prototype at all, so *no* key — known or future — can resolve through it. + * `@objectstack/spec`'s own map is left untouched (out of scope here; the + * identical shape in its `classifyFilterToken` is objectstack#17762). + */ +const NEAR_MISS_SUGGESTIONS: Readonly> = Object.assign( + Object.create(null), + CONTEXT_TOKEN_SUGGESTIONS, +); + /** * Expand `{current_user_id}` / `{current_org_id}` inside a filter. * @@ -174,7 +197,7 @@ export function resolveContextTokens(filter: T, scope: FilterTokenScope // `organization_id` is a real column name. The map only makes the runtime // warning actionable; the authoring-time gate (`validateFilterTokens` in // `@objectstack/lint`) is what actually prevents these from shipping. - const suggestion = CONTEXT_TOKEN_SUGGESTIONS[token.toLowerCase()]; + const suggestion = NEAR_MISS_SUGGESTIONS[token.toLowerCase()]; if (suggestion) { warn( `Filter placeholder "{${token}}" is not a recognised token — did you mean ` +