Skip to content
Closed
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
1 change: 0 additions & 1 deletion packages/core/src/core_private_export.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,6 @@ export {_sanitizeHtml as ɵ_sanitizeHtml} from './sanitization/html_sanitizer';
export {_sanitizeUrl as ɵ_sanitizeUrl} from './sanitization/url_sanitizer';
export {setAlternateWeakRefImpl as ɵsetAlternateWeakRefImpl} from './signals';
export {TESTABILITY as ɵTESTABILITY, TESTABILITY_GETTER as ɵTESTABILITY_GETTER} from './testability/testability';
export {escapeTransferStateContent as ɵescapeTransferStateContent, unescapeTransferStateContent as ɵunescapeTransferStateContent} from './transfer_state';
export {coerceToBoolean as ɵcoerceToBoolean} from './util/coercion';
export {devModeEqual as ɵdevModeEqual} from './util/comparison';
export {global as ɵglobal} from './util/global';
Expand Down
35 changes: 9 additions & 26 deletions packages/core/src/transfer_state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,28 +11,6 @@ import {inject} from './di/injector_compatibility';
import {ɵɵdefineInjectable} from './di/interface/defs';
import {getDocument} from './render3/interfaces/document';

export function escapeTransferStateContent(text: string): string {
const escapedText: {[k: string]: string} = {
'&': '&a;',
'"': '&q;',
'\'': '&s;',
'<': '&l;',
'>': '&g;',
};
return text.replace(/[&"'<>]/g, s => escapedText[s]);
}

export function unescapeTransferStateContent(text: string): string {
const unescapedText: {[k: string]: string} = {
'&a;': '&',
'&q;': '"',
'&s;': '\'',
'&l;': '<',
'&g;': '>',
};
return text.replace(/&[^;]+;/g, s => unescapedText[s]);
}

/**
* A type-safe key to use with `TransferState`.
*
Expand Down Expand Up @@ -70,7 +48,7 @@ export function makeStateKey<T = void>(key: string): StateKey<T> {
return key as StateKey<T>;
}

function initTransferState() {
function initTransferState(): TransferState {
const transferState = new TransferState();
if (inject(PLATFORM_ID) === 'browser') {
transferState.store = retrieveTransferredState(getDocument(), inject(APP_ID));
Expand Down Expand Up @@ -132,7 +110,7 @@ export class TransferState {
/**
* Test whether a key exists in the store.
*/
hasKey<T>(key: StateKey<T>) {
hasKey<T>(key: StateKey<T>): boolean {
return this.store.hasOwnProperty(key);
}

Expand Down Expand Up @@ -164,7 +142,10 @@ export class TransferState {
}
}
}
return JSON.stringify(this.store);

// Escape script tag to avoid break out of <script> tag in serialized output.
// Encoding of `<` is the same behaviour as G3 script_builders.
return JSON.stringify(this.store).replace(/</g, '\\u003C');
}
}

Expand All @@ -175,7 +156,9 @@ function retrieveTransferredState(doc: Document, appId: string): Record<string,
if (script?.textContent) {
try {
// Avoid using any here as it triggers lint errors in google3 (any is not allowed).
return JSON.parse(unescapeTransferStateContent(script.textContent)) as {};
// Decoding of `<` is done of the box by browsers and node.js, same behaviour as G3
// script_builders.
return JSON.parse(script.textContent) as {};
} catch (e) {
console.warn('Exception while restoring TransferState for app ' + appId, e);
}
Expand Down
40 changes: 23 additions & 17 deletions packages/core/test/transfer_state_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,22 +10,21 @@ import {APP_ID as APP_ID_TOKEN, PLATFORM_ID} from '@angular/core';
import {TestBed} from '@angular/core/testing';

import {getDocument} from '../src/render3/interfaces/document';
import {escapeTransferStateContent, makeStateKey, TransferState, unescapeTransferStateContent} from '../src/transfer_state';
import {makeStateKey, TransferState} from '../src/transfer_state';

(function() {
function removeScriptTag(doc: Document, id: string) {
const existing = doc.getElementById(id);
if (existing) {
doc.body.removeChild(existing);
}
}

function addScriptTag(doc: Document, appId: string, data: {}) {
function addScriptTag(doc: Document, appId: string, data: object|string) {
const script = doc.createElement('script');
const id = appId + '-state';
script.id = id;
script.setAttribute('type', 'application/json');
script.textContent = escapeTransferStateContent(JSON.stringify(data));
script.textContent = typeof data === 'string' ? data : JSON.stringify(data);

// Remove any stale script tags.
removeScriptTag(doc, id);
Expand Down Expand Up @@ -129,19 +128,26 @@ describe('TransferState', () => {
transferState.remove(TEST_KEY);
expect(transferState.isEmpty).toBeTrue();
});
});

describe('escape/unescape', () => {
it('works with all escaped characters', () => {
const testString = '</script><script>alert(\'Hello&\' + "World");';
const testObj = {testString};
const escaped = escapeTransferStateContent(JSON.stringify(testObj));
expect(escaped).toBe(
'{&q;testString&q;:&q;&l;/script&g;&l;script&g;' +
'alert(&s;Hello&a;&s; + \\&q;World\\&q;);&q;}');

const unescapedObj = JSON.parse(unescapeTransferStateContent(escaped)) as {testString: string};
expect(unescapedObj['testString']).toBe(testString);
it('should encode `<` to avoid breaking out of <script> tag in serialized output', () => {
const transferState = TestBed.inject(TransferState);

// The state is empty initially.
expect(transferState.isEmpty).toBeTrue();

transferState.set(DELAYED_KEY, '</script><script>alert(\'Hello&\' + "World");');
expect(transferState.toJson())
.toBe(`{"delayed":"\\u003C/script>\\u003Cscript>alert('Hello&' + \\"World\\");"}`);
});

it('should decode `\\u003C` (<) when restoring stating', () => {
const encodedState =
`{"delayed":"\\u003C/script>\\u003Cscript>alert('Hello&' + \\"World\\");"}`;
addScriptTag(doc, APP_ID, encodedState);
const transferState = TestBed.inject(TransferState);

expect(transferState.toJson()).toBe(encodedState);
expect(transferState.get(DELAYED_KEY, null))
.toBe('</script><script>alert(\'Hello&\' + "World");');
});
});
})();
4 changes: 2 additions & 2 deletions packages/platform-server/src/transfer_state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
*/

import {DOCUMENT} from '@angular/common';
import {APP_ID, NgModule, Provider, TransferState, ɵescapeTransferStateContent as escapeTransferStateContent} from '@angular/core';
import {APP_ID, NgModule, Provider, TransferState} from '@angular/core';

import {BEFORE_APP_SERIALIZED} from './tokens';

Expand All @@ -33,7 +33,7 @@ function serializeTransferStateFactory(doc: Document, appId: string, transferSto
const script = doc.createElement('script');
script.id = appId + '-state';
script.setAttribute('type', 'application/json');
script.textContent = escapeTransferStateContent(content);
script.textContent = content;

// It is intentional that we add the script at the very bottom. Angular CLI script tags for
// bundles are always `type="module"`. These are deferred by default and cause the transfer
Expand Down
6 changes: 2 additions & 4 deletions packages/platform-server/test/hydration_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ import {ApplicationRef, Component, ComponentRef, createComponent, destroyPlatfor
import {Console} from '@angular/core/src/console';
import {InitialRenderPendingTasks} from '@angular/core/src/initial_render_pending_tasks';
import {getComponentDef} from '@angular/core/src/render3/definition';
import {unescapeTransferStateContent} from '@angular/core/src/transfer_state';
import {NoopNgZone} from '@angular/core/src/zone/ng_zone';
import {TestBed} from '@angular/core/testing';
import {bootstrapApplication, HydrationFeature, HydrationFeatureKind, provideClientHydration, withNoDomReuse} from '@angular/platform-browser';
Expand Down Expand Up @@ -185,9 +184,8 @@ function resetTViewsFor(...types: Type<unknown>[]) {
}
}

function getHydrationInfoFromTransferState(input: string): string|null {
const rawContents = input.match(/<script[^>]+>(.*?)<\/script>/)?.[1];
return rawContents ? unescapeTransferStateContent(rawContents) : null;
function getHydrationInfoFromTransferState(input: string): string|undefined {
return input.match(/<script[^>]+>(.*?)<\/script>/)?.[1];
}

function withNoopErrorHandler() {
Expand Down
2 changes: 1 addition & 1 deletion packages/platform-server/test/integration_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -842,7 +842,7 @@ describe('platform-server integration', () => {
renderModule(MyTransferStateModule, options);
const expectedOutput =
'<html><head></head><body><app ng-version="0.0.0-PLACEHOLDER" ng-server-context="other"><div>Works!</div></app>' +
'<script id="ng-state" type="application/json">{&q;some-key&q;:&q;some-value&q;}</script></body></html>';
'<script id="ng-state" type="application/json">{"some-key":"some-value"}</script></body></html>';
const output = await bootstrap;
expect(output).toEqual(expectedOutput);
});
Expand Down
6 changes: 3 additions & 3 deletions packages/platform-server/test/transfer_state_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import {renderModule, ServerModule} from '@angular/platform-server';

describe('transfer_state', () => {
const defaultExpectedOutput =
'<html><head></head><body><app ng-version="0.0.0-PLACEHOLDER" ng-server-context="other">Works!</app><script id="ng-state" type="application/json">{&q;test&q;:10}</script></body></html>';
'<html><head></head><body><app ng-version="0.0.0-PLACEHOLDER" ng-server-context="other">Works!</app><script id="ng-state" type="application/json">{"test":10}</script></body></html>';

it('adds transfer script tag when using renderModule', async () => {
const STATE_KEY = makeStateKey<number>('test');
Expand Down Expand Up @@ -58,8 +58,8 @@ describe('transfer_state', () => {
expect(output).toBe(
'<html><head></head><body><esc-app ng-version="0.0.0-PLACEHOLDER" ng-server-context="other">Works!</esc-app>' +
'<script id="ng-state" type="application/json">' +
'{&q;testString&q;:&q;&l;/script&g;&l;script&g;' +
'alert(&s;Hello&a;&s; + \\&q;World\\&q;);&q;}</script></body></html>');
`{"testString":"\\u003C/script>\\u003Cscript>alert('Hello&' + \\"World\\");"}` +
'</script></body></html>');
});

it('adds transfer script tag when setting state during onSerialize', async () => {
Expand Down