fix(core): prevent TransferState prototype pollution

Store transfer state and serialization callbacks in null-prototype dictionaries, and only read values that belong to the store. This keeps special state keys from changing lookup behavior or exposing inherited cache entries.

Fixes #70265

(cherry picked from commit 168a324cce)
This commit is contained in:
SkyZeroZx
2026-08-19 10:08:06 -05:00
committed by leonsenft
parent 5dfe753bfc
commit 7c752d4815
3 changed files with 64 additions and 5 deletions
+18 -5
View File
@@ -48,6 +48,11 @@ export function makeStateKey<T = void>(key: string): StateKey<T> {
return key as StateKey<T>;
}
function createDictionary<T>(): Record<string, T> {
// StateKey can be any string, including names of special Object prototype properties.
return Object.create(null);
}
/**
* A key value store that is transferred from the application on the server side to the application
* on the client side.
@@ -79,15 +84,20 @@ export class TransferState {
});
/** @internal */
store: Record<string, unknown | undefined> = {};
store: Record<string, unknown | undefined> = createDictionary();
private onSerializeCallbacks: {[k: string]: () => unknown | undefined} = {};
private onSerializeCallbacks: {[k: string]: () => unknown | undefined} = createDictionary();
/**
* Get the value corresponding to a key. Return `defaultValue` if key is not found.
*/
get<T>(key: StateKey<T>, defaultValue: T): T {
return this.store[key] !== undefined ? (this.store[key] as T) : defaultValue;
if (!Object.hasOwn(this.store, key)) {
return defaultValue;
}
const value = this.store[key];
return value !== undefined ? (value as T) : defaultValue;
}
/**
@@ -159,11 +169,14 @@ export function retrieveTransferredState(
// Avoid using any here as it triggers lint errors in google3 (any is not allowed).
// Decoding of `<` is done of the box by browsers and node.js, same behaviour as G3
// script_builders.
return JSON.parse(script.textContent) as {};
return Object.assign(
createDictionary<unknown | undefined>(),
JSON.parse(script.textContent) as {},
);
} catch (e) {
console.warn('Exception while restoring TransferState for app ' + appId, e);
}
}
return {};
return createDictionary();
}
@@ -435,6 +435,7 @@
"createContainerAnchorImpl",
"createDeferBlockInjector",
"createDehydratedBlockRegistry",
"createDictionary",
"createDirectivesInstances",
"createElementNode",
"createElementRef",
+45
View File
@@ -91,6 +91,31 @@ describe('TransferState', () => {
expect(transferState.hasKey(TEST_KEY)).toBe(true);
});
it('does not read inherited properties', () => {
const transferState = TestBed.inject(TransferState);
const constructorKey = makeStateKey<unknown>('constructor');
const store = (transferState as unknown as {store: Record<string, unknown>}).store;
Object.setPrototypeOf(store, {constructor: 'inherited'});
expect(transferState.get(constructorKey, 'default')).toBe('default');
expect(transferState.hasKey(constructorKey)).toBeFalse();
});
it('supports keys that name object prototype properties', () => {
const transferState = TestBed.inject(TransferState);
const prototypeKey = makeStateKey<{cached: boolean}>('__proto__');
const value = {cached: true};
transferState.set(prototypeKey, value);
expect(transferState.get(prototypeKey, {cached: false})).toBe(value);
expect(transferState.hasKey(prototypeKey)).toBeTrue();
transferState.remove(prototypeKey);
expect(transferState.get(prototypeKey, null!)).toBeNull();
});
it("supports setting and accessing value '0' via get", () => {
const transferState: TransferState = TestBed.inject(TransferState);
transferState.set(TEST_KEY, 0);
@@ -137,6 +162,16 @@ describe('TransferState', () => {
expect(transferState.toJson()).toBe('{"test":20,"delayed":"changed"}');
});
it('calls onSerialize callbacks whose keys name object prototype properties', () => {
const transferState = TestBed.inject(TransferState);
const prototypeKey = makeStateKey<string>('__proto__');
transferState.onSerialize(prototypeKey, () => 'serialized');
expect(transferState.toJson()).toBe('{"__proto__":"serialized"}');
expect(transferState.get(prototypeKey, null!)).toBe('serialized');
});
it('should provide an ability to detect whether the state is empty', () => {
const transferState = TestBed.inject(TransferState);
@@ -190,4 +225,14 @@ describe('TransferState', () => {
expect(transferState.get(DELAYED_KEY, null)).toBe(relativeLink);
expect(transferState.toJson()).toBe(encodedState);
});
it('restores keys that name object prototype properties into a dictionary', () => {
addScriptTag(doc, APP_ID, '{"__proto__":{"cached":true},"constructor":"state"}');
const transferState = TestBed.inject(TransferState);
expect(transferState.get(makeStateKey<{cached: boolean}>('__proto__'), null)).toEqual({
cached: true,
});
expect(transferState.get(makeStateKey<string>('constructor'), null)).toBe('state');
});
});