Skip to content
Draft
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
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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];
Expand Down
130 changes: 130 additions & 0 deletions packages/core/primitives/event-dispatch/test/dispatcher_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
});
});
Loading