From 1b731739bb658b81bcfe612f820d7679f93431b7 Mon Sep 17 00:00:00 2001 From: iaohkut Date: Mon, 13 Jul 2026 11:04:48 -0400 Subject: [PATCH 1/4] fix: prevent prototype hijack in theme safeMerge (CWE-1321) 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 --- src/theme/provider.tsx | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/theme/provider.tsx b/src/theme/provider.tsx index b4101eaa0f..4da4909183 100644 --- a/src/theme/provider.tsx +++ b/src/theme/provider.tsx @@ -29,6 +29,11 @@ export const isPlatformColorSentinel = (v: unknown): boolean => typeof v === 'object' && ('resource_paths' in v || 'semantic' in v || 'dynamic' in v); +// Keys that would otherwise let a crafted `overrides` object (e.g. parsed via +// `JSON.parse` from an external theme config) hijack the prototype of the +// merged output through bracket-notation assignment below. +const UNSAFE_KEYS = new Set(['__proto__', 'constructor', 'prototype']); + export const safeMerge = (base: T, overrides: unknown): T => { if ( !base || @@ -45,6 +50,9 @@ export const safeMerge = (base: T, overrides: unknown): T => { } const out: Record = { ...(base as Record) }; for (const key of Object.keys(overrides as Record)) { + if (UNSAFE_KEYS.has(key)) { + continue; + } out[key] = safeMerge( (base as Record)[key], (overrides as Record)[key] From 76fc178e9f1edd085125b61e7044fc1182da333f Mon Sep 17 00:00:00 2001 From: John Fanidis Date: Thu, 29 Oct 2026 18:50:42 +0200 Subject: [PATCH 2/4] Remove duplicate loop for processing overrides --- src/theme/provider.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/theme/provider.tsx b/src/theme/provider.tsx index b28490c00b..7e0b6a71d9 100644 --- a/src/theme/provider.tsx +++ b/src/theme/provider.tsx @@ -54,7 +54,6 @@ export const safeMerge = (base: T, overrides: unknown): T => { if (UNSAFE_KEYS.has(key)) { continue; } - for (const key of Object.keys(overrides)) { out[key] = safeMerge( (base as Record)[key], (overrides as Record)[key] From 45603611255ca269cd091b47b3b7c17f292b2753 Mon Sep 17 00:00:00 2001 From: Doberjohn Date: Fri, 6 Nov 2026 00:57:12 +0200 Subject: [PATCH 3/4] test(theme): cover prototype keys in safeMerge --- src/theme/__tests__/provider.test.ts | 43 ++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/src/theme/__tests__/provider.test.ts b/src/theme/__tests__/provider.test.ts index acac88b491..b1f421be36 100644 --- a/src/theme/__tests__/provider.test.ts +++ b/src/theme/__tests__/provider.test.ts @@ -124,6 +124,49 @@ describe('safeMerge', () => { expect(result.colors.secondary).toBe('#999'); expect(result.colors.tertiary).toBe('#222'); }); + + it('ignores a top-level __proto__ key', () => { + const overrides = JSON.parse('{"__proto__": {"polluted": "yes"}, "a": 2}'); + + const result = safeMerge>({ a: 1 }, overrides); + expect(Object.getPrototypeOf(result)).toBe(Object.prototype); + expect(result).toEqual({ a: 2 }); + expect(result.polluted).toBeUndefined(); + const fresh: Record = {}; + expect(fresh.polluted).toBeUndefined(); + }); + + it('ignores a nested __proto__ key', () => { + const base = { colors: { primary: 'blue', secondary: 'green' } }; + const overrides = JSON.parse( + '{"colors": {"__proto__": {"polluted": "yes"}, "primary": "red"}}' + ); + + const result = safeMerge<{ colors: Record }>( + base, + overrides + ); + expect(Object.getPrototypeOf(result.colors)).toBe(Object.prototype); + expect(result.colors.primary).toBe('red'); + expect(result.colors.secondary).toBe('green'); + expect(result.colors.polluted).toBeUndefined(); + }); + + it('skips constructor and prototype keys', () => { + const overrides = JSON.parse( + '{"constructor": {"x": 1}, "prototype": {"y": 2}, "a": 2}' + ); + + const result = safeMerge>({ a: 1 }, overrides); + expect(Object.prototype.hasOwnProperty.call(result, 'constructor')).toBe( + false + ); + expect(Object.prototype.hasOwnProperty.call(result, 'prototype')).toBe( + false + ); + expect(result.constructor).toBe(Object); + expect(result).toEqual({ a: 2 }); + }); }); describe('useInternalTheme', () => { From c2ed3ac9ca86dfc55addbadc15bc42625072f3e4 Mon Sep 17 00:00:00 2001 From: Doberjohn Date: Fri, 6 Nov 2026 00:58:58 +0200 Subject: [PATCH 4/4] chore(theme): remove the UNSAFE_KEYS comment --- src/theme/provider.tsx | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/theme/provider.tsx b/src/theme/provider.tsx index 7e0b6a71d9..11d697bd19 100644 --- a/src/theme/provider.tsx +++ b/src/theme/provider.tsx @@ -29,9 +29,6 @@ export const isPlatformColorSentinel = (v: unknown): boolean => typeof v === 'object' && ('resource_paths' in v || 'semantic' in v || 'dynamic' in v); -// Keys that would otherwise let a crafted `overrides` object (e.g. parsed via -// `JSON.parse` from an external theme config) hijack the prototype of the -// merged output through bracket-notation assignment below. const UNSAFE_KEYS = new Set(['__proto__', 'constructor', 'prototype']); /* eslint-disable @typescript-eslint/no-unsafe-type-assertion */