From 768a349e6e54ff16deba4c1bfe12be9d0f55f443 Mon Sep 17 00:00:00 2001 From: Matthieu Riegler Date: Mon, 1 Jun 2026 22:09:41 +0200 Subject: [PATCH] fix(core): harden TransferState restoration against DOM clobbering Reject non-script elements when reading the SSR transfer state payload by id. This prevents attacker-controlled elements with a clobbered id from spoofing hydration state. --- packages/core/src/transfer_state.ts | 2 +- packages/core/test/transfer_state_spec.ts | 24 ++++++++++++++++++++++- 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/packages/core/src/transfer_state.ts b/packages/core/src/transfer_state.ts index 786f6974e4f..d9242c26229 100644 --- a/packages/core/src/transfer_state.ts +++ b/packages/core/src/transfer_state.ts @@ -155,7 +155,7 @@ export function retrieveTransferredState( // Locate the script tag with the JSON data transferred from the server. // The id of the script tag is set to the Angular appId + 'state'. const script = doc.getElementById(appId + '-state'); - if (script?.textContent) { + if (script?.tagName === 'SCRIPT' && script.textContent) { try { // 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 diff --git a/packages/core/test/transfer_state_spec.ts b/packages/core/test/transfer_state_spec.ts index 7b773e000ab..c392b1cce89 100644 --- a/packages/core/test/transfer_state_spec.ts +++ b/packages/core/test/transfer_state_spec.ts @@ -13,7 +13,11 @@ import {getDocument} from '../src/render3/interfaces/document'; import {makeStateKey, TransferState} from '../src/transfer_state'; function removeScriptTag(doc: Document, id: string) { - doc.getElementById(id)?.remove(); + let node = doc.getElementById(id); + while (node) { + node.remove(); + node = doc.getElementById(id); + } } function addScriptTag(doc: Document, appId: string, data: object | string) { @@ -57,6 +61,24 @@ describe('TransferState', () => { expect(transferState.get(TEST_KEY, 0)).toBe(10); }); + it('ignores non-script elements that clobber the transfer state id', () => { + const id = APP_ID + '-state'; + + const clobberingNode = doc.createElement('div'); + clobberingNode.id = id; + clobberingNode.textContent = '{"test":999}'; + doc.body.appendChild(clobberingNode); + + const script = doc.createElement('script'); + script.id = id; + script.setAttribute('type', 'application/json'); + script.textContent = '{"test":10}'; + doc.body.appendChild(script); + + const transferState: TransferState = TestBed.inject(TransferState); + expect(transferState.get(TEST_KEY, 0)).toBe(0); + }); + it('is initialized to empty state if script tag not found', () => { const transferState: TransferState = TestBed.inject(TransferState); expect(transferState.get(TEST_KEY, 0)).toBe(0);