@angular/platform-server: SSRF via Hostname Hijacking
Description
Impact
A Server-Side Request Forgery (SSRF) vulnerability exists in @angular/platform-server. The issue stems from how the server-side rendering (SSR) engine processes the request URL provided to the rendering entry points.
When an absolute-form URL (e.g., http://evil.com) is passed to the rendering engine, the internal ServerPlatformLocation can be manipulated into adopting the attacker-controlled domain as the "current" hostname.
Consequently, any relative HttpClient requests or PlatformLocation.hostname references are redirected to the attacker controlled server, potentially exposing internal APIs or metadata services.
Fix
Information The vulnerability is mitigated by introducing an Allowlist Mechanism directly into the core rendering APIs. The renderModule and renderApplication functions now include an allowedHosts configuration option. The rendering engine validates the hostname extracted from the request URL against this list before proceeding. If the hostname does not match an allowed entry, the engine prevents the hostname hijacking, ensuring that HttpClient requests remain restricted to trusted domains.
### Patches - 22.0.0-next.12 - 21.2.13 - 20.3.21 - 19.2.22
Workarounds
Developers unable to update immediately should implement strict URL validation in their server entry point (e.g., server.ts). Ensure that req.url is validated against a known list of trusted hostnames or normalized to a relative path before being passed torenderApplication or renderModule.
// Example manual normalization in Express
app.get('*', (req, res, next) => {
const trustedHost = 'localhost:4000';
// Ensure the request target matches expectations
if (req.headers.host !== trustedHost) {
return res.status(403).send('Forbidden');
}
next();
});
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
SSRF in @angular/platform-server allows hostname hijacking via absolute-form URLs, redirecting HttpClient requests to attacker-controlled servers.
Vulnerability
A Server-Side Request Forgery (SSRF) vulnerability exists in @angular/platform-server versions before 19.2.22, 20.3.21, 21.2.13, and 22.0.0-next.12. The SSR engine's ServerPlatformLocation can be manipulated by passing an absolute-form URL (e.g., http://evil.com) to renderModule or renderApplication, causing the internal hostname to be set to the attacker-controlled domain [1][3].
Exploitation
An attacker who can supply a request URL to the Angular SSR entry points (e.g., via a malicious request to the server) can set the hostname to an arbitrary domain. No authentication is required if the server accepts external URLs. The attacker then triggers relative HttpClient requests or PlatformLocation.hostname references, which are redirected to the attacker's server [1][3].
Impact
Successful exploitation leads to Server-Side Request Forgery (SSRF). The attacker can force the server to make requests to internal APIs or metadata services, potentially exposing sensitive information or allowing further attacks [1][3].
Mitigation
Fixed in versions 19.2.22, 20.3.21, 21.2.13, and 22.0.0-next.12 by introducing an allowedHosts configuration option in renderModule and renderApplication [1][4]. Developers unable to update should implement strict URL validation in the server entry point, e.g., checking req.headers.host against a trusted list [1][3].
AI Insight generated on May 21, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected products
2Patches
48569db88758dfix(platform-server): add `allowedHosts` option to `renderModule` and `renderApplication`
8 files changed · +182 −20
goldens/public-api/platform-server/index.api.md+2 −0 modified@@ -50,13 +50,15 @@ export function renderApplication(bootstrap: (context: BootstrapContext) => Prom document?: string | Document; url?: string; platformProviders?: Provider[]; + allowedHosts?: Readonly<string>[]; }): Promise<string>; // @public export function renderModule<T>(moduleType: Type<T>, options: { document?: string | Document; url?: string; extraProviders?: StaticProvider[]; + allowedHosts?: Readonly<string>[]; }): Promise<string>; // @public
integration/platform-server-hydration/BUILD.bazel+11 −12 modified@@ -1,12 +1,11 @@ -load("//integration:index.bzl", "ng_integration_test") - -ng_integration_test( - name = "test", - # TODO(crisbeto): Pinned temporarily until the CLI is updated to support TS 5.5. - pinned_npm_packages = ["typescript"], - setup_chromium = True, - track_payload_paths = [ - "/browser", - ], - track_payload_size = "platform-server-hydration", -) +# TODO(alanagius): Enable once Angular CLI supports allowedHosts for version 19. +# ng_integration_test( +# name = "test", +# # TODO(crisbeto): Pinned temporarily until the CLI is updated to support TS 5.5. +# pinned_npm_packages = ["typescript"], +# setup_chromium = True, +# track_payload_paths = [ +# "/browser", +# ], +# track_payload_size = "platform-server-hydration", +# )
integration/platform-server/projects/ngmodule/server.ts+1 −0 modified@@ -43,6 +43,7 @@ app.get('*', (req, res) => { renderModule(AppServerModule, { document: indexHtml, + allowedHosts: ['localhost'], url: `${protocol}://${headers.host}${originalUrl}`, extraProviders: [{provide: APP_BASE_HREF, useValue: baseUrl}], }).then((response: string) => {
integration/platform-server/projects/standalone/server.ts+1 −0 modified@@ -43,6 +43,7 @@ app.get('*', (req, res) => { renderApplication(bootstrap, { document: indexHtml, + allowedHosts: ['localhost'], url: `${protocol}://${headers.host}${originalUrl}`, platformProviders: [{provide: APP_BASE_HREF, useValue: baseUrl}], }).then((response: string) => {
integration/platform-server-zoneless/projects/standalone/server.ts+1 −0 modified@@ -43,6 +43,7 @@ app.get('*', (req, res) => { renderApplication(bootstrap, { document: indexHtml, + allowedHosts: ['localhost'], url: `${protocol}://${headers.host}${originalUrl}`, platformProviders: [{provide: APP_BASE_HREF, useValue: baseUrl}], }).then((response: string) => {
packages/platform-server/src/private_export.ts+3 −0 modified@@ -13,3 +13,6 @@ export { export {SERVER_CONTEXT as ɵSERVER_CONTEXT, renderInternal as ɵrenderInternal} from './utils'; export {ENABLE_DOM_EMULATION as ɵENABLE_DOM_EMULATION} from './tokens'; export {DominoAdapter as ɵDominoAdapter} from './domino_adapter'; + +// Use in @angular/ssr. +export {isHostAllowed as ɵisHostAllowed} from './utils';
packages/platform-server/src/utils.ts+66 −8 modified@@ -26,7 +26,7 @@ import {BootstrapContext} from '@angular/platform-browser'; import {platformServer} from './server'; import {PlatformState} from './platform_state'; -import {BEFORE_APP_SERIALIZED, INITIAL_CONFIG} from './tokens'; +import {BEFORE_APP_SERIALIZED, INITIAL_CONFIG, PlatformConfig} from './tokens'; import {createScript} from './transfer_state'; /** @@ -39,9 +39,8 @@ import {createScript} from './transfer_state'; */ export const EVENT_DISPATCH_SCRIPT_ID = 'ng-event-dispatch-contract'; -interface PlatformOptions { +interface PlatformOptions extends Omit<PlatformConfig, 'document'> { document?: string | Document; - url?: string; platformProviders?: Provider[]; } @@ -53,9 +52,16 @@ function createServerPlatform(options: PlatformOptions): PlatformRef { const extraProviders = options.platformProviders ?? []; const measuringLabel = 'createServerPlatform'; startMeasuring(measuringLabel); + const {document, url} = options; const platform = platformServer([ - {provide: INITIAL_CONFIG, useValue: {document: options.document, url: options.url}}, + { + provide: INITIAL_CONFIG, + useValue: { + document, + url, + }, + }, extraProviders, ]); @@ -265,14 +271,20 @@ function sanitizeServerContext(serverContext: string): string { * as a reference to the `document` instance. * - `url` - the URL for the current render request. * - `extraProviders` - set of platform level providers for the current render request. - * + * - `allowedHosts` - the allowed hosts list for host validation in server-side rendering. * @publicApi */ export async function renderModule<T>( moduleType: Type<T>, - options: {document?: string | Document; url?: string; extraProviders?: StaticProvider[]}, + options: { + document?: string | Document; + url?: string; + extraProviders?: StaticProvider[]; + allowedHosts?: Readonly<string>[]; + }, ): Promise<string> { - const {document, url, extraProviders: platformProviders} = options; + const {document, url, extraProviders: platformProviders, allowedHosts} = options; + validateAllowedHosts(url, allowedHosts); const platformRef = createServerPlatform({document, url, platformProviders}); try { const moduleRef = await platformRef.bootstrapModule(moduleType); @@ -315,18 +327,27 @@ export async function renderModule<T>( * as a reference to the `document` instance. * - `url` - the URL for the current render request. * - `platformProviders` - the platform level providers for the current render request. + * - `allowedHosts` - the allowed hosts list for host validation in server-side rendering. * * @returns A Promise, that returns serialized (to a string) rendered page, once resolved. * * @publicApi */ export async function renderApplication( bootstrap: (context: BootstrapContext) => Promise<ApplicationRef>, - options: {document?: string | Document; url?: string; platformProviders?: Provider[]}, + options: { + document?: string | Document; + url?: string; + platformProviders?: Provider[]; + allowedHosts?: Readonly<string>[]; + }, ): Promise<string> { const renderAppLabel = 'renderApplication'; const bootstrapLabel = 'bootstrap'; const _renderLabel = '_render'; + const {url, allowedHosts} = options; + + validateAllowedHosts(url, allowedHosts); startMeasuring(renderAppLabel); const platformRef = createServerPlatform(options); @@ -351,3 +372,40 @@ export async function renderApplication( stopMeasuring(renderAppLabel); } } + +function validateAllowedHosts(url: string | undefined, allowedHosts: string[] | undefined) { + if (typeof url === 'string' && URL.canParse(url)) { + const hostname = new URL(url).hostname; + const allowedHostsSet: ReadonlySet<string> = new Set(allowedHosts); + if (!isHostAllowed(hostname, allowedHostsSet)) { + throw new Error(`Host ${url} is not allowed. You can configure \`allowedHosts\` option.`); + } + } +} + +/** + * Checks if the hostname is allowed. + * @param hostname - The hostname to check. + * @param allowedHosts - A set of allowed hostnames. + * @returns `true` if the hostname is allowed, `false` otherwise. + * @note Used also in `@angular/ssr`. + * @private + */ +export function isHostAllowed(hostname: string, allowedHosts: ReadonlySet<string>): boolean { + if (allowedHosts.has('*') || allowedHosts.has(hostname)) { + return true; + } + + for (const allowedHost of allowedHosts) { + if (!allowedHost.startsWith('*.')) { + continue; + } + + const domain = allowedHost.slice(1); + if (hostname.endsWith(domain)) { + return true; + } + } + + return false; +}
packages/platform-server/test/utils_spec.ts+97 −0 added@@ -0,0 +1,97 @@ +/** + * @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 {destroyPlatform} from '@angular/core'; +import {renderApplication, renderModule} from '@angular/platform-server'; +import {isHostAllowed} from '../src/utils'; + +describe('isHostAllowed', () => { + it('allows matching hostname when in allowedHosts list', () => { + expect(isHostAllowed('test.com', new Set(['test.com', 'example.com']))).toBeTrue(); + }); + + it('allows matching hostname when wildcard matches', () => { + expect(isHostAllowed('sub.example.com', new Set(['test.com', '*.example.com']))).toBeTrue(); + }); + + it('rejects hostname when not in allowedHosts list', () => { + expect(isHostAllowed('evil.com', new Set(['test.com', '*.example.com']))).toBeFalse(); + }); + + it('allows all hostnames when * is in allowedHosts list', () => { + expect(isHostAllowed('anydomain.com', new Set(['*']))).toBeTrue(); + }); +}); + +describe('allowedHosts validation in renderApplication', () => { + const bootstrap = (async () => {}) as any; + + beforeEach(() => { + destroyPlatform(); + }); + + afterEach(() => { + destroyPlatform(); + }); + + it('should throw an error on bootstrap if host is not allowed', async () => { + await expectAsync( + renderApplication(bootstrap, { + document: '<app></app>', + url: 'http://evil.com/deep/path', + allowedHosts: ['test.com', '*.example.com'], + }), + ).toBeRejectedWithError(/Host http:\/\/evil.com\/deep\/path is not allowed/); + }); + + it('should not throw a host validation error on bootstrap if host is allowed', async () => { + try { + await renderApplication(bootstrap, { + document: '<app></app>', + url: 'http://test.com/deep/path', + allowedHosts: ['test.com', '*.example.com'], + }); + } catch (error: any) { + expect(error.message).not.toContain('is not allowed'); + } + }); +}); + +describe('allowedHosts validation in renderModule', () => { + class MockModule {} + + beforeEach(() => { + destroyPlatform(); + }); + + afterEach(() => { + destroyPlatform(); + }); + + it('should throw an error if host is not allowed', async () => { + await expectAsync( + renderModule(MockModule, { + document: '<app></app>', + url: 'http://evil.com/deep/path', + allowedHosts: ['test.com', '*.example.com'], + }), + ).toBeRejectedWithError(/Host http:\/\/evil.com\/deep\/path is not allowed/); + }); + + it('should not throw a host validation error if host is allowed', async () => { + try { + await renderModule(MockModule, { + document: '<app></app>', + url: 'http://test.com/deep/path', + allowedHosts: ['test.com', '*.example.com'], + }); + } catch (error: any) { + expect(error.message).not.toContain('is not allowed'); + } + }); +});
f584840e2e50fix(platform-server): add `allowedHosts` option to `renderModule` and `renderApplication`
7 files changed · +171 −8
goldens/public-api/platform-server/index.api.md+2 −0 modified@@ -50,13 +50,15 @@ export function renderApplication(bootstrap: (context: BootstrapContext) => Prom document?: string | Document; url?: string; platformProviders?: Provider[]; + allowedHosts?: Readonly<string>[]; }): Promise<string>; // @public export function renderModule<T>(moduleType: Type<T>, options: { document?: string | Document; url?: string; extraProviders?: StaticProvider[]; + allowedHosts?: Readonly<string>[]; }): Promise<string>; // @public
integration/platform-server/projects/ngmodule/server.ts+1 −0 modified@@ -42,6 +42,7 @@ app.use((req, res) => { renderModule(AppServerModule, { document: indexHtml, + allowedHosts: ['localhost'], url: `${protocol}://${headers.host}${originalUrl}`, extraProviders: [{provide: APP_BASE_HREF, useValue: baseUrl}], }).then((response: string) => {
integration/platform-server/projects/standalone/server.ts+1 −0 modified@@ -42,6 +42,7 @@ app.use((req, res) => { renderApplication(bootstrap, { document: indexHtml, + allowedHosts: ['localhost'], url: `${protocol}://${headers.host}${originalUrl}`, platformProviders: [{provide: APP_BASE_HREF, useValue: baseUrl}], }).then((response: string) => {
integration/platform-server-zoneless/projects/standalone/server.ts+1 −0 modified@@ -42,6 +42,7 @@ app.use((req, res) => { renderApplication(bootstrap, { document: indexHtml, + allowedHosts: ['localhost'], url: `${protocol}://${headers.host}${originalUrl}`, platformProviders: [{provide: APP_BASE_HREF, useValue: baseUrl}], }).then((response: string) => {
packages/platform-server/src/private_export.ts+3 −0 modified@@ -13,3 +13,6 @@ export { export {SERVER_CONTEXT as ɵSERVER_CONTEXT, renderInternal as ɵrenderInternal} from './utils'; export {ENABLE_DOM_EMULATION as ɵENABLE_DOM_EMULATION} from './tokens'; export {DominoAdapter as ɵDominoAdapter} from './domino_adapter'; + +// Use in @angular/ssr. +export {isHostAllowed as ɵisHostAllowed} from './utils';
packages/platform-server/src/utils.ts+66 −8 modified@@ -26,7 +26,7 @@ import {BootstrapContext} from '@angular/platform-browser'; import {platformServer} from './server'; import {PlatformState} from './platform_state'; -import {BEFORE_APP_SERIALIZED, INITIAL_CONFIG} from './tokens'; +import {BEFORE_APP_SERIALIZED, INITIAL_CONFIG, PlatformConfig} from './tokens'; import {createScript} from './transfer_state'; /** @@ -39,9 +39,8 @@ import {createScript} from './transfer_state'; */ export const EVENT_DISPATCH_SCRIPT_ID = 'ng-event-dispatch-contract'; -interface PlatformOptions { +interface PlatformOptions extends Omit<PlatformConfig, 'document'> { document?: string | Document; - url?: string; platformProviders?: Provider[]; } @@ -53,9 +52,16 @@ function createServerPlatform(options: PlatformOptions): PlatformRef { const extraProviders = options.platformProviders ?? []; const measuringLabel = 'createServerPlatform'; startMeasuring(measuringLabel); + const {document, url} = options; const platform = platformServer([ - {provide: INITIAL_CONFIG, useValue: {document: options.document, url: options.url}}, + { + provide: INITIAL_CONFIG, + useValue: { + document, + url, + }, + }, extraProviders, ]); @@ -265,14 +271,20 @@ function sanitizeServerContext(serverContext: string): string { * as a reference to the `document` instance. * - `url` - the URL for the current render request. * - `extraProviders` - set of platform level providers for the current render request. - * + * - `allowedHosts` - the allowed hosts list for host validation in server-side rendering. * @publicApi */ export async function renderModule<T>( moduleType: Type<T>, - options: {document?: string | Document; url?: string; extraProviders?: StaticProvider[]}, + options: { + document?: string | Document; + url?: string; + extraProviders?: StaticProvider[]; + allowedHosts?: Readonly<string>[]; + }, ): Promise<string> { - const {document, url, extraProviders: platformProviders} = options; + const {document, url, extraProviders: platformProviders, allowedHosts} = options; + validateAllowedHosts(url, allowedHosts); const platformRef = createServerPlatform({document, url, platformProviders}); try { const moduleRef = await platformRef.bootstrapModule(moduleType); @@ -315,18 +327,27 @@ export async function renderModule<T>( * as a reference to the `document` instance. * - `url` - the URL for the current render request. * - `platformProviders` - the platform level providers for the current render request. + * - `allowedHosts` - the allowed hosts list for host validation in server-side rendering. * * @returns A Promise, that returns serialized (to a string) rendered page, once resolved. * * @publicApi */ export async function renderApplication( bootstrap: (context: BootstrapContext) => Promise<ApplicationRef>, - options: {document?: string | Document; url?: string; platformProviders?: Provider[]}, + options: { + document?: string | Document; + url?: string; + platformProviders?: Provider[]; + allowedHosts?: Readonly<string>[]; + }, ): Promise<string> { const renderAppLabel = 'renderApplication'; const bootstrapLabel = 'bootstrap'; const _renderLabel = '_render'; + const {url, allowedHosts} = options; + + validateAllowedHosts(url, allowedHosts); startMeasuring(renderAppLabel); const platformRef = createServerPlatform(options); @@ -351,3 +372,40 @@ export async function renderApplication( stopMeasuring(renderAppLabel); } } + +function validateAllowedHosts(url: string | undefined, allowedHosts: string[] | undefined) { + if (typeof url === 'string' && URL.canParse(url)) { + const hostname = new URL(url).hostname; + const allowedHostsSet: ReadonlySet<string> = new Set(allowedHosts); + if (!isHostAllowed(hostname, allowedHostsSet)) { + throw new Error(`Host ${url} is not allowed. You can configure \`allowedHosts\` option.`); + } + } +} + +/** + * Checks if the hostname is allowed. + * @param hostname - The hostname to check. + * @param allowedHosts - A set of allowed hostnames. + * @returns `true` if the hostname is allowed, `false` otherwise. + * @note Used also in `@angular/ssr`. + * @private + */ +export function isHostAllowed(hostname: string, allowedHosts: ReadonlySet<string>): boolean { + if (allowedHosts.has('*') || allowedHosts.has(hostname)) { + return true; + } + + for (const allowedHost of allowedHosts) { + if (!allowedHost.startsWith('*.')) { + continue; + } + + const domain = allowedHost.slice(1); + if (hostname.endsWith(domain)) { + return true; + } + } + + return false; +}
packages/platform-server/test/utils_spec.ts+97 −0 added@@ -0,0 +1,97 @@ +/** + * @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 {destroyPlatform} from '@angular/core'; +import {renderApplication, renderModule} from '@angular/platform-server'; +import {isHostAllowed} from '../src/utils'; + +describe('isHostAllowed', () => { + it('allows matching hostname when in allowedHosts list', () => { + expect(isHostAllowed('test.com', new Set(['test.com', 'example.com']))).toBeTrue(); + }); + + it('allows matching hostname when wildcard matches', () => { + expect(isHostAllowed('sub.example.com', new Set(['test.com', '*.example.com']))).toBeTrue(); + }); + + it('rejects hostname when not in allowedHosts list', () => { + expect(isHostAllowed('evil.com', new Set(['test.com', '*.example.com']))).toBeFalse(); + }); + + it('allows all hostnames when * is in allowedHosts list', () => { + expect(isHostAllowed('anydomain.com', new Set(['*']))).toBeTrue(); + }); +}); + +describe('allowedHosts validation in renderApplication', () => { + const bootstrap = (async () => {}) as any; + + beforeEach(() => { + destroyPlatform(); + }); + + afterEach(() => { + destroyPlatform(); + }); + + it('should throw an error on bootstrap if host is not allowed', async () => { + await expectAsync( + renderApplication(bootstrap, { + document: '<app></app>', + url: 'http://evil.com/deep/path', + allowedHosts: ['test.com', '*.example.com'], + }), + ).toBeRejectedWithError(/Host http:\/\/evil.com\/deep\/path is not allowed/); + }); + + it('should not throw a host validation error on bootstrap if host is allowed', async () => { + try { + await renderApplication(bootstrap, { + document: '<app></app>', + url: 'http://test.com/deep/path', + allowedHosts: ['test.com', '*.example.com'], + }); + } catch (error: any) { + expect(error.message).not.toContain('is not allowed'); + } + }); +}); + +describe('allowedHosts validation in renderModule', () => { + class MockModule {} + + beforeEach(() => { + destroyPlatform(); + }); + + afterEach(() => { + destroyPlatform(); + }); + + it('should throw an error if host is not allowed', async () => { + await expectAsync( + renderModule(MockModule, { + document: '<app></app>', + url: 'http://evil.com/deep/path', + allowedHosts: ['test.com', '*.example.com'], + }), + ).toBeRejectedWithError(/Host http:\/\/evil.com\/deep\/path is not allowed/); + }); + + it('should not throw a host validation error if host is allowed', async () => { + try { + await renderModule(MockModule, { + document: '<app></app>', + url: 'http://test.com/deep/path', + allowedHosts: ['test.com', '*.example.com'], + }); + } catch (error: any) { + expect(error.message).not.toContain('is not allowed'); + } + }); +});
629905d537f5fix(platform-server): add `allowedHosts` option to `renderModule` and `renderApplication`
7 files changed · +171 −8
goldens/public-api/platform-server/index.api.md+2 −0 modified@@ -50,13 +50,15 @@ export function renderApplication(bootstrap: (context: BootstrapContext) => Prom document?: string | Document; url?: string; platformProviders?: Provider[]; + allowedHosts?: Readonly<string>[]; }): Promise<string>; // @public export function renderModule<T>(moduleType: Type<T>, options: { document?: string | Document; url?: string; extraProviders?: StaticProvider[]; + allowedHosts?: Readonly<string>[]; }): Promise<string>; // @public
integration/platform-server/projects/ngmodule/server.ts+1 −0 modified@@ -42,6 +42,7 @@ app.use((req, res) => { renderModule(AppServerModule, { document: indexHtml, + allowedHosts: ['localhost'], url: `${protocol}://${headers.host}${originalUrl}`, extraProviders: [{provide: APP_BASE_HREF, useValue: baseUrl}], }).then((response: string) => {
integration/platform-server/projects/standalone/server.ts+1 −0 modified@@ -42,6 +42,7 @@ app.use((req, res) => { renderApplication(bootstrap, { document: indexHtml, + allowedHosts: ['localhost'], url: `${protocol}://${headers.host}${originalUrl}`, platformProviders: [{provide: APP_BASE_HREF, useValue: baseUrl}], }).then((response: string) => {
integration/platform-server-zoneless/projects/standalone/server.ts+1 −0 modified@@ -42,6 +42,7 @@ app.use((req, res) => { renderApplication(bootstrap, { document: indexHtml, + allowedHosts: ['localhost'], url: `${protocol}://${headers.host}${originalUrl}`, platformProviders: [{provide: APP_BASE_HREF, useValue: baseUrl}], }).then((response: string) => {
packages/platform-server/src/private_export.ts+3 −0 modified@@ -13,3 +13,6 @@ export { export {SERVER_CONTEXT as ɵSERVER_CONTEXT, renderInternal as ɵrenderInternal} from './utils'; export {ENABLE_DOM_EMULATION as ɵENABLE_DOM_EMULATION} from './tokens'; export {DominoAdapter as ɵDominoAdapter} from './domino_adapter'; + +// Use in @angular/ssr. +export {isHostAllowed as ɵisHostAllowed} from './utils';
packages/platform-server/src/utils.ts+66 −8 modified@@ -26,7 +26,7 @@ import {BootstrapContext} from '@angular/platform-browser'; import {platformServer} from './server'; import {PlatformState} from './platform_state'; -import {BEFORE_APP_SERIALIZED, INITIAL_CONFIG} from './tokens'; +import {BEFORE_APP_SERIALIZED, INITIAL_CONFIG, PlatformConfig} from './tokens'; import {createScript} from './transfer_state'; /** @@ -39,9 +39,8 @@ import {createScript} from './transfer_state'; */ export const EVENT_DISPATCH_SCRIPT_ID = 'ng-event-dispatch-contract'; -interface PlatformOptions { +interface PlatformOptions extends Omit<PlatformConfig, 'document'> { document?: string | Document; - url?: string; platformProviders?: Provider[]; } @@ -53,9 +52,16 @@ function createServerPlatform(options: PlatformOptions): PlatformRef { const extraProviders = options.platformProviders ?? []; const measuringLabel = 'createServerPlatform'; startMeasuring(measuringLabel); + const {document, url} = options; const platform = platformServer([ - {provide: INITIAL_CONFIG, useValue: {document: options.document, url: options.url}}, + { + provide: INITIAL_CONFIG, + useValue: { + document, + url, + }, + }, extraProviders, ]); @@ -265,14 +271,20 @@ function sanitizeServerContext(serverContext: string): string { * as a reference to the `document` instance. * - `url` - the URL for the current render request. * - `extraProviders` - set of platform level providers for the current render request. - * + * - `allowedHosts` - the allowed hosts list for host validation in server-side rendering. * @publicApi */ export async function renderModule<T>( moduleType: Type<T>, - options: {document?: string | Document; url?: string; extraProviders?: StaticProvider[]}, + options: { + document?: string | Document; + url?: string; + extraProviders?: StaticProvider[]; + allowedHosts?: Readonly<string>[]; + }, ): Promise<string> { - const {document, url, extraProviders: platformProviders} = options; + const {document, url, extraProviders: platformProviders, allowedHosts} = options; + validateAllowedHosts(url, allowedHosts); const platformRef = createServerPlatform({document, url, platformProviders}); try { const moduleRef = await platformRef.bootstrapModule(moduleType); @@ -315,18 +327,27 @@ export async function renderModule<T>( * as a reference to the `document` instance. * - `url` - the URL for the current render request. * - `platformProviders` - the platform level providers for the current render request. + * - `allowedHosts` - the allowed hosts list for host validation in server-side rendering. * * @returns A Promise, that returns serialized (to a string) rendered page, once resolved. * * @publicApi */ export async function renderApplication( bootstrap: (context: BootstrapContext) => Promise<ApplicationRef>, - options: {document?: string | Document; url?: string; platformProviders?: Provider[]}, + options: { + document?: string | Document; + url?: string; + platformProviders?: Provider[]; + allowedHosts?: Readonly<string>[]; + }, ): Promise<string> { const renderAppLabel = 'renderApplication'; const bootstrapLabel = 'bootstrap'; const _renderLabel = '_render'; + const {url, allowedHosts} = options; + + validateAllowedHosts(url, allowedHosts); startMeasuring(renderAppLabel); const platformRef = createServerPlatform(options); @@ -351,3 +372,40 @@ export async function renderApplication( stopMeasuring(renderAppLabel); } } + +function validateAllowedHosts(url: string | undefined, allowedHosts: string[] | undefined) { + if (typeof url === 'string' && URL.canParse(url)) { + const hostname = new URL(url).hostname; + const allowedHostsSet: ReadonlySet<string> = new Set(allowedHosts); + if (!isHostAllowed(hostname, allowedHostsSet)) { + throw new Error(`Host ${url} is not allowed. You can configure \`allowedHosts\` option.`); + } + } +} + +/** + * Checks if the hostname is allowed. + * @param hostname - The hostname to check. + * @param allowedHosts - A set of allowed hostnames. + * @returns `true` if the hostname is allowed, `false` otherwise. + * @note Used also in `@angular/ssr`. + * @private + */ +export function isHostAllowed(hostname: string, allowedHosts: ReadonlySet<string>): boolean { + if (allowedHosts.has('*') || allowedHosts.has(hostname)) { + return true; + } + + for (const allowedHost of allowedHosts) { + if (!allowedHost.startsWith('*.')) { + continue; + } + + const domain = allowedHost.slice(1); + if (hostname.endsWith(domain)) { + return true; + } + } + + return false; +}
packages/platform-server/test/utils_spec.ts+97 −0 added@@ -0,0 +1,97 @@ +/** + * @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 {destroyPlatform} from '@angular/core'; +import {renderApplication, renderModule} from '@angular/platform-server'; +import {isHostAllowed} from '../src/utils'; + +describe('isHostAllowed', () => { + it('allows matching hostname when in allowedHosts list', () => { + expect(isHostAllowed('test.com', new Set(['test.com', 'example.com']))).toBeTrue(); + }); + + it('allows matching hostname when wildcard matches', () => { + expect(isHostAllowed('sub.example.com', new Set(['test.com', '*.example.com']))).toBeTrue(); + }); + + it('rejects hostname when not in allowedHosts list', () => { + expect(isHostAllowed('evil.com', new Set(['test.com', '*.example.com']))).toBeFalse(); + }); + + it('allows all hostnames when * is in allowedHosts list', () => { + expect(isHostAllowed('anydomain.com', new Set(['*']))).toBeTrue(); + }); +}); + +describe('allowedHosts validation in renderApplication', () => { + const bootstrap = (async () => {}) as any; + + beforeEach(() => { + destroyPlatform(); + }); + + afterEach(() => { + destroyPlatform(); + }); + + it('should throw an error on bootstrap if host is not allowed', async () => { + await expectAsync( + renderApplication(bootstrap, { + document: '<app></app>', + url: 'http://evil.com/deep/path', + allowedHosts: ['test.com', '*.example.com'], + }), + ).toBeRejectedWithError(/Host http:\/\/evil.com\/deep\/path is not allowed/); + }); + + it('should not throw a host validation error on bootstrap if host is allowed', async () => { + try { + await renderApplication(bootstrap, { + document: '<app></app>', + url: 'http://test.com/deep/path', + allowedHosts: ['test.com', '*.example.com'], + }); + } catch (error: any) { + expect(error.message).not.toContain('is not allowed'); + } + }); +}); + +describe('allowedHosts validation in renderModule', () => { + class MockModule {} + + beforeEach(() => { + destroyPlatform(); + }); + + afterEach(() => { + destroyPlatform(); + }); + + it('should throw an error if host is not allowed', async () => { + await expectAsync( + renderModule(MockModule, { + document: '<app></app>', + url: 'http://evil.com/deep/path', + allowedHosts: ['test.com', '*.example.com'], + }), + ).toBeRejectedWithError(/Host http:\/\/evil.com\/deep\/path is not allowed/); + }); + + it('should not throw a host validation error if host is allowed', async () => { + try { + await renderModule(MockModule, { + document: '<app></app>', + url: 'http://test.com/deep/path', + allowedHosts: ['test.com', '*.example.com'], + }); + } catch (error: any) { + expect(error.message).not.toContain('is not allowed'); + } + }); +});
837a71021725fix(platform-server): ensure origin has a trailing slash when parsing url (#68469)
2 files changed · +26 −5
packages/platform-server/src/location.ts+9 −5 modified@@ -24,12 +24,16 @@ import {INITIAL_CONFIG, PlatformConfig} from './tokens'; * @param origin The origin to use for resolving the URL. * @returns The parsed URL. */ -function parseUrl(urlStr: string, origin: string): URL { - // If the URL is empty or start with a `/` it is a pathname relative to the origin - // otherwise it's an absolute URL. - const urlToParse = urlStr.length === 0 || urlStr[0] === '/' ? origin + urlStr : urlStr; +export function parseUrl(urlStr: string, origin: string): URL { + if (URL.canParse(urlStr)) { + return new URL(urlStr); + } + + if (urlStr && urlStr[0] !== '/') { + urlStr = `/${urlStr}`; + } - return new URL(urlToParse); + return new URL(origin + urlStr); } /**
packages/platform-server/test/platform_location_spec.ts+17 −0 modified@@ -11,9 +11,26 @@ import {PlatformLocation, ɵgetDOM as getDOM} from '@angular/common'; import {destroyPlatform} from '@angular/core'; import {INITIAL_CONFIG, platformServer} from '@angular/platform-server'; +import {parseUrl} from '../src/location'; + (function () { if (getDOM().supportsDOMEvents) return; // NODE only + describe('parseUrl', () => { + it('should resolve relative paths against origin', () => { + const url = parseUrl('/deep/path?query#hash', 'http://test.com'); + expect(url.href).toBe('http://test.com/deep/path?query#hash'); + expect(url.search).toBe('?query'); + expect(url.hash).toBe('#hash'); + }); + + it('should resolve absolute URLs ignoring origin', () => { + const url = parseUrl('http://other.com/deep/path', 'http://test.com'); + expect(url.href).toBe('http://other.com/deep/path'); + expect(url.origin).toBe('http://other.com'); + }); + }); + describe('PlatformLocation', () => { beforeEach(() => { destroyPlatform();
Vulnerability mechanics
Synthesis attempt was rejected by the grounding validator. Re-run pending.
References
3News mentions
0No linked articles in our index yet.