Skip to content

fix: prevent prototype hijack in theme safeMerge (CWE-1321)#5027

Open
alanturing881 wants to merge 1 commit into
callstack:mainfrom
alanturing881:fix/theme-safemerge-proto-hijack
Open

fix: prevent prototype hijack in theme safeMerge (CWE-1321)#5027
alanturing881 wants to merge 1 commit into
callstack:mainfrom
alanturing881:fix/theme-safemerge-proto-hijack

Conversation

@alanturing881

Copy link
Copy Markdown

Summary

safeMerge in src/theme/provider.tsx (used by every component's theme prop override via useInternalTheme) recursively merges the override object into a fresh copy of the base theme using bracket-notation assignment:

const out: Record<string, unknown> = { ...(base as Record<string, unknown>) };
for (const key of Object.keys(overrides as Record<string, unknown>)) {
  out[key] = safeMerge(base[key], overrides[key]);
}

Unlike object-literal spread ({...obj}), which copies a property literally named "__proto__" as an ordinary own data property, bracket-notation assignment invokes the Object.prototype.__proto__ accessor. If overrides (e.g. parsed via JSON.parse from an external/remote theme config) has an own key named "__proto__", then out["__proto__"] = value calls Object.setPrototypeOf(out, value) — hijacking the prototype of the object safeMerge is building instead of storing it as a normal property.

Proof

const overrides = JSON.parse('{"colors": {"__proto__": {"polluted": "yes"}}}');
const merged = safeMerge({ colors: { primary: 'blue' } }, overrides);

merged.colors.polluted; // => "yes"  (before this fix)

This is scoped to the specific object safeMerge returns (it does not reach the shared global Object.prototype), so the blast radius is limited — but it's still a real CWE-1321 gap in a function whose name and surrounding comment ("Upstream deepmerge corrupts PlatformColor objects, so we recurse manually") signal it's meant to be a safe, hardened replacement.

Realistic impact

Applications that build per-component theme overrides from external/remote JSON (e.g. white-label/dynamic-branding apps) and pass that data straight into a component's theme prop without validating its shape are exposed. Because the merge starts from {...base}, keys already present in the real theme schema are shadowed by own properties, so the practical effect is limited to forging fallback values for undefined theme property lookups — not overriding existing, actively-used theme values.

Fix

Add a denylist for __proto__/constructor/prototype so safeMerge never assigns those keys, closing the hijack regardless of what shape overrides takes:

const UNSAFE_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
...
for (const key of Object.keys(overrides as Record<string, unknown>)) {
  if (UNSAFE_KEYS.has(key)) {
    continue;
  }
  out[key] = safeMerge(...);
}

Test plan

  • Verified in an isolated Node harness that the exploit (__proto__ key in overrides hijacking the merged object's prototype) is blocked after the fix.
  • Verified legitimate deep-merge behavior (overriding existing keys like colors.primary, adding new keys, leaving untouched keys intact) is unchanged.
  • Verified PlatformColor/DynamicColorIOS sentinel objects are still treated as leaves (not walked into) as before.
  • Full existing test suite passes (yarn jest): 732 passed, 1 skipped, 0 failed.
  • tsc --noEmit and eslint clean on the changed file.

🤖 Generated with Claude Code

safeMerge() recursively merges component `theme` overrides using
bracket-notation assignment (`out[key] = ...`). Unlike object-literal
spread, bracket assignment invokes the `Object.prototype.__proto__`
accessor, so a crafted overrides object with an own `__proto__` key
(e.g. produced by JSON.parse of an external theme config) hijacks the
prototype of the merged object instead of being stored as a normal
property.

Add a denylist for `__proto__`/`constructor`/`prototype` keys so they
are skipped during the merge, matching the function's stated intent
of being a *safe* merge. Verified the fix blocks the hijack while
preserving legitimate deep-merge behavior and PlatformColor sentinel
handling; full existing test suite passes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant