angular/packages/platform-server/test/transfer_state_spec.ts
Jessica Janiuk f603d4714f fix(core): escape forward slashes in transfer state to prevent crawler indexing
This commit escapes forward slashes in the transfer state JSON output as \u002F to prevent search engine crawlers from aggressively indexing relative paths inside the inline script tag. It also updates related unit and integration tests across core and platform-server.

Fixes #65310

(cherry picked from commit 3c7641151c)
2026-04-13 13:55:00 +03:00

164 lines
5.1 KiB
TypeScript

/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/
import {
APP_ID,
Component,
makeStateKey,
NgModule,
TransferState,
ɵgetTransferState as getTransferState,
Injector,
inject,
DOCUMENT,
} from '@angular/core';
import {BrowserModule, withEventReplay, withIncrementalHydration} from '@angular/platform-browser';
import {renderModule, ServerModule} from '../index';
import {getHydrationInfoFromTransferState, ssr} from './hydration_utils';
import domino from '../third_party/domino/bundled-domino';
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">{"test":10}</script></body></html>';
it('adds transfer script tag when using renderModule', async () => {
const STATE_KEY = makeStateKey<number>('test');
@Component({
selector: 'app',
template: 'Works!',
standalone: false,
})
class TransferComponent {
constructor(private transferStore: TransferState) {
this.transferStore.set(STATE_KEY, 10);
}
}
@NgModule({
bootstrap: [TransferComponent],
declarations: [TransferComponent],
imports: [BrowserModule, ServerModule],
})
class TransferStoreModule {}
const output = await renderModule(TransferStoreModule, {document: '<app></app>'});
expect(output).toBe(defaultExpectedOutput);
});
it('cannot break out of <script> tag in serialized output', async () => {
const STATE_KEY = makeStateKey<string>('testString');
@Component({
selector: 'esc-app',
template: 'Works!',
standalone: false,
})
class EscapedComponent {
constructor(private transferStore: TransferState) {
this.transferStore.set(STATE_KEY, '</script><script>alert(\'Hello&\' + "World");');
}
}
@NgModule({
bootstrap: [EscapedComponent],
declarations: [EscapedComponent],
imports: [BrowserModule, ServerModule],
})
class EscapedTransferStoreModule {}
const output = await renderModule(EscapedTransferStoreModule, {
document: '<esc-app></esc-app>',
});
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">' +
`{"testString":"\\u003C\\u002Fscript>\\u003Cscript>alert('Hello&' + \\"World\\");"}` +
'</script></body></html>',
);
});
it('adds transfer script tag when setting state during onSerialize', async () => {
const STATE_KEY = makeStateKey<number>('test');
@Component({
selector: 'app',
template: 'Works!',
standalone: false,
})
class TransferComponent {
constructor(private transferStore: TransferState) {
this.transferStore.onSerialize(STATE_KEY, () => 10);
}
}
@NgModule({
bootstrap: [TransferComponent],
declarations: [TransferComponent],
imports: [BrowserModule, ServerModule],
})
class TransferStoreModule {}
const output = await renderModule(TransferStoreModule, {document: '<app></app>'});
expect(output).toBe(defaultExpectedOutput);
});
describe('getTransferState', () => {
it('ensures it only returns public info of the Transfer State', async () => {
@Component({
selector: 'dep',
template: ``,
})
class Dep {}
@Component({
selector: 'app',
imports: [Dep],
template: `
<!-- This defer block will add internal defer data to the transfer state -->
@defer (hydrate on interaction) {
<dep />
}
`,
})
class SimpleComponent {
constructor() {
// This is adds a data to the transfer state.
inject(TransferState).set<string>(makeStateKey('test'), 'testitest');
}
}
const hydrationFeatures = () => [withIncrementalHydration(), withEventReplay()];
const appId = 'custom-app-id';
const html = await ssr(SimpleComponent, {
envProviders: [{provide: APP_ID, useValue: appId}],
hydrationFeatures,
});
const transferCacheJson = getHydrationInfoFromTransferState(html)!;
// getTransferState reaches into the DOM to retrieve the transfer state.
// So we need to set the document with the generated HTML.
const {document} = domino.createWindow(html);
const transferState = getTransferState(
Injector.create({
providers: [
{provide: DOCUMENT, useValue: document},
{provide: APP_ID, useValue: appId},
],
}),
);
// The transfer state also contains internal hydration keys,
expect(Object.keys(transferState).length).not.toEqual(JSON.parse(transferCacheJson).length);
// We only retrieve the public data from the transfer state.
expect(Object.keys(transferState)).toEqual(['test']);
});
});
});