From b99427993e14ac58d622233514b4a733b203a96c Mon Sep 17 00:00:00 2001 From: arturovt Date: Sun, 31 May 2026 22:14:45 +0300 Subject: [PATCH] fix(core): prevent prototype pollution via jsaction attribute parsing The `jsaction` attribute parser in `parseActions` previously stored event-action mappings in a plain object (`{}`) and used event type strings from DOM attributes directly as property keys. This meant that specially crafted event names such as `__proto__`, `constructor`, or `prototype` could interact with the object's prototype chain rather than behaving as ordinary data keys. The module-level `parseCache` had a similar issue. It was also implemented as a plain object keyed by the raw `jsaction` attribute value, allowing special property names to collide with built-in object properties. This change hardens both code paths by: * Replacing `actionMap = {}` with `Object.create(null)`, creating an object with no prototype chain. * Replacing `parseCache = {}` with `Object.create(null)` for the same reason. --- .../event-dispatch/src/action_resolver.ts | 9 +- .../event-dispatch/test/dispatcher_test.ts | 130 ++++++++++++++++++ 2 files changed, 137 insertions(+), 2 deletions(-) diff --git a/packages/core/primitives/event-dispatch/src/action_resolver.ts b/packages/core/primitives/event-dispatch/src/action_resolver.ts index 4505e6aebf6e..c9f18bb54882 100644 --- a/packages/core/primitives/event-dispatch/src/action_resolver.ts +++ b/packages/core/primitives/event-dispatch/src/action_resolver.ts @@ -19,7 +19,8 @@ import * as eventLib from './event'; * Since maps from event to action are immutable we can use a single map * to represent the empty map. */ -const EMPTY_ACTION_MAP: {[key: string]: string} = {}; +// tslint:disable-next-line:no-toplevel-property-access +const EMPTY_ACTION_MAP: {[key: string]: string} = /* @__PURE__ */ Object.create(null); /** * This regular expression matches a semicolon. @@ -258,7 +259,11 @@ export class ActionResolver { } else { actionMap = cache.getParsed(jsactionAttribute); if (!actionMap) { - actionMap = {}; + // Use Object.create(null) instead of {} to produce an object with + // no prototype chain, making __proto__, constructor, and prototype + // keys inert — they become plain own properties rather than + // touching Object.prototype or the object's constructor. + actionMap = Object.create(null) as {[key: string]: string | undefined}; const values = jsactionAttribute.split(REGEXP_SEMICOLON); for (let idx = 0; idx < values.length; idx++) { const value = values[idx]; diff --git a/packages/core/primitives/event-dispatch/test/dispatcher_test.ts b/packages/core/primitives/event-dispatch/test/dispatcher_test.ts index c2d64cd43900..09551e320ebc 100644 --- a/packages/core/primitives/event-dispatch/test/dispatcher_test.ts +++ b/packages/core/primitives/event-dispatch/test/dispatcher_test.ts @@ -1129,4 +1129,134 @@ describe('Dispatcher', () => { expect(eventInfoWrapper.getAction()).toBeUndefined(); }); }); + + describe('prototype pollution prevention', () => { + it('ignores __proto__ as event type in jsaction attribute', () => { + const container = getRequiredElementById('click-container'); + const targetElement = getRequiredElementById('click-target-element'); + const actionElement = getRequiredElementById('click-action-element'); + + // Simulate attacker-controlled jsaction attribute + actionElement.setAttribute('jsaction', '__proto__:evil;click:handleClick'); + cache.clear(actionElement); + + const eventContract = createEventContract({ + container, + eventTypes: ['click'], + }); + const dispatchDelegate = createDispatchDelegateSpy(); + createDispatcher({dispatchDelegate, eventContract}); + + dispatchMouseEvent(targetElement); + + // __proto__ key must not pollute Object.prototype + expect(({} as any).evil).toBeUndefined(); + + // Legitimate click action still resolves correctly + const eventInfoWrapper = dispatchDelegate.calls.mostRecent().args[0]; + expect(eventInfoWrapper.getAction()?.name).toBe('handleClick'); + }); + + it('ignores constructor as event type in jsaction attribute', () => { + const container = getRequiredElementById('click-container'); + const targetElement = getRequiredElementById('click-target-element'); + const actionElement = getRequiredElementById('click-action-element'); + + actionElement.setAttribute('jsaction', 'constructor:evil;click:handleClick'); + cache.clear(actionElement); + + const eventContract = createEventContract({ + container, + eventTypes: ['click'], + }); + const dispatchDelegate = createDispatchDelegateSpy(); + createDispatcher({dispatchDelegate, eventContract}); + + dispatchMouseEvent(targetElement); + + // constructor must not be shadowed on the action map + const eventInfoWrapper = dispatchDelegate.calls.mostRecent().args[0]; + const action = eventInfoWrapper.getAction(); + expect(action?.name).toBe('handleClick'); + + // Verify constructor was not written as an own property on the parsed map + // by checking that a freshly parsed map still has a normal constructor + expect(typeof {}.constructor).toBe('function'); + }); + + it('ignores prototype as event type in jsaction attribute', () => { + const container = getRequiredElementById('click-container'); + const targetElement = getRequiredElementById('click-target-element'); + const actionElement = getRequiredElementById('click-action-element'); + + actionElement.setAttribute('jsaction', 'prototype:evil;click:handleClick'); + cache.clear(actionElement); + + const eventContract = createEventContract({ + container, + eventTypes: ['click'], + }); + const dispatchDelegate = createDispatchDelegateSpy(); + createDispatcher({dispatchDelegate, eventContract}); + + dispatchMouseEvent(targetElement); + + const eventInfoWrapper = dispatchDelegate.calls.mostRecent().args[0]; + expect(eventInfoWrapper.getAction()?.name).toBe('handleClick'); + }); + + it('does not pollute parseCache via __proto__ jsaction value', () => { + const container = getRequiredElementById('click-container'); + const targetElement = getRequiredElementById('click-target-element'); + const actionElement = getRequiredElementById('click-action-element'); + + // The full jsaction string itself is used as the parseCache key — + // if parseCache is a plain {}, setting parseCache["__proto__"] could + // affect Object.prototype in some engines. + actionElement.setAttribute('jsaction', '__proto__'); + cache.clear(actionElement); + + const eventContract = createEventContract({ + container, + eventTypes: ['click'], + }); + const dispatchDelegate = createDispatchDelegateSpy(); + createDispatcher({dispatchDelegate, eventContract}); + + dispatchMouseEvent(targetElement); + + // Object.prototype must be clean after parsing + expect(({} as any).click).toBeUndefined(); + expect(({} as any).handleClick).toBeUndefined(); + }); + + it('handles multiple dangerous keys mixed with legitimate ones', () => { + const container = getRequiredElementById('click-container'); + const targetElement = getRequiredElementById('click-target-element'); + const actionElement = getRequiredElementById('click-action-element'); + + actionElement.setAttribute( + 'jsaction', + '__proto__:evil1;constructor:evil2;prototype:evil3;click:handleClick', + ); + cache.clear(actionElement); + + const eventContract = createEventContract({ + container, + eventTypes: ['click'], + }); + const dispatchDelegate = createDispatchDelegateSpy(); + createDispatcher({dispatchDelegate, eventContract}); + + dispatchMouseEvent(targetElement); + + // All dangerous keys skipped, legitimate action still works + expect(({} as any).evil1).toBeUndefined(); + expect(({} as any).evil2).toBeUndefined(); + expect(({} as any).evil3).toBeUndefined(); + + const eventInfoWrapper = dispatchDelegate.calls.mostRecent().args[0]; + expect(eventInfoWrapper.getAction()?.name).toBe('handleClick'); + }); + }); });