Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .changeset/9129-filter-token-suggestion-proto-guard.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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}"?');
});
});
25 changes: 24 additions & 1 deletion packages/core/src/utils/filter-tokens.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<string, ContextTokenName>> = Object.assign(
Object.create(null),
CONTEXT_TOKEN_SUGGESTIONS,
);

/**
* Expand `{current_user_id}` / `{current_org_id}` inside a filter.
*
Expand Down Expand Up @@ -174,7 +197,7 @@ export function resolveContextTokens<T = any>(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 ` +
Expand Down
Loading