From 8569db88758d189544b03ec4474fd5334ff29346 Mon Sep 17 00:00:00 2001 From: Alan Agius <17563226+alan-agius4@users.noreply.github.com> Date: Wed, 6 May 2026 13:03:40 +0000 Subject: [PATCH] fix(platform-server): add `allowedHosts` option to `renderModule` and `renderApplication` In server-side rendering (SSR) setups, passing request URLs directly to the lower-level rendering APIs `renderModule` or `renderApplication` can expose applications to Server-Side Request Forgery (SSRF) or Host Header Injection attacks via absolute-form request URLs. To mitigate these vulnerabilities at the framework layer, this commit introduces the `allowedHosts` option to `PlatformConfig` (supporting exact hostnames, wildcards like `*.example.com`, or `*` to allow all). During platform initialization inside `createServerPlatform`, the hostname of the request `url` is validated against the `allowedHosts` list. If the hostname is not authorized, bootstrap immediately throws a host validation error, preventing unauthorized rendering and silent SSRF bypasses. Closes #68436 --- .../public-api/platform-server/index.api.md | 2 + .../platform-server-hydration/BUILD.bazel | 23 +++-- .../projects/standalone/server.ts | 1 + .../projects/ngmodule/server.ts | 1 + .../projects/standalone/server.ts | 1 + .../platform-server/src/private_export.ts | 3 + packages/platform-server/src/utils.ts | 74 ++++++++++++-- packages/platform-server/test/utils_spec.ts | 97 +++++++++++++++++++ 8 files changed, 182 insertions(+), 20 deletions(-) create mode 100644 packages/platform-server/test/utils_spec.ts diff --git a/goldens/public-api/platform-server/index.api.md b/goldens/public-api/platform-server/index.api.md index 476de591676..2578436d547 100644 --- a/goldens/public-api/platform-server/index.api.md +++ b/goldens/public-api/platform-server/index.api.md @@ -50,6 +50,7 @@ export function renderApplication(bootstrap: (context: BootstrapContext) => Prom document?: string | Document; url?: string; platformProviders?: Provider[]; + allowedHosts?: Readonly[]; }): Promise; // @public @@ -57,6 +58,7 @@ export function renderModule(moduleType: Type, options: { document?: string | Document; url?: string; extraProviders?: StaticProvider[]; + allowedHosts?: Readonly[]; }): Promise; // @public diff --git a/integration/platform-server-hydration/BUILD.bazel b/integration/platform-server-hydration/BUILD.bazel index 235c0e8ef49..6335e310602 100644 --- a/integration/platform-server-hydration/BUILD.bazel +++ b/integration/platform-server-hydration/BUILD.bazel @@ -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", +# ) diff --git a/integration/platform-server-zoneless/projects/standalone/server.ts b/integration/platform-server-zoneless/projects/standalone/server.ts index 0a2eaef76b4..10139ccaaf3 100644 --- a/integration/platform-server-zoneless/projects/standalone/server.ts +++ b/integration/platform-server-zoneless/projects/standalone/server.ts @@ -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) => { diff --git a/integration/platform-server/projects/ngmodule/server.ts b/integration/platform-server/projects/ngmodule/server.ts index cea1cb531c8..ec7686489f1 100644 --- a/integration/platform-server/projects/ngmodule/server.ts +++ b/integration/platform-server/projects/ngmodule/server.ts @@ -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) => { diff --git a/integration/platform-server/projects/standalone/server.ts b/integration/platform-server/projects/standalone/server.ts index 986474ae253..da7912db594 100644 --- a/integration/platform-server/projects/standalone/server.ts +++ b/integration/platform-server/projects/standalone/server.ts @@ -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) => { diff --git a/packages/platform-server/src/private_export.ts b/packages/platform-server/src/private_export.ts index 6529cf24007..eae9c0e2ee0 100644 --- a/packages/platform-server/src/private_export.ts +++ b/packages/platform-server/src/private_export.ts @@ -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'; diff --git a/packages/platform-server/src/utils.ts b/packages/platform-server/src/utils.ts index 92baf0b6038..0199afd83fa 100644 --- a/packages/platform-server/src/utils.ts +++ b/packages/platform-server/src/utils.ts @@ -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 { 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( moduleType: Type, - options: {document?: string | Document; url?: string; extraProviders?: StaticProvider[]}, + options: { + document?: string | Document; + url?: string; + extraProviders?: StaticProvider[]; + allowedHosts?: Readonly[]; + }, ): Promise { - 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,6 +327,7 @@ export async function renderModule( * 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. * @@ -322,11 +335,19 @@ export async function renderModule( */ export async function renderApplication( bootstrap: (context: BootstrapContext) => Promise, - options: {document?: string | Document; url?: string; platformProviders?: Provider[]}, + options: { + document?: string | Document; + url?: string; + platformProviders?: Provider[]; + allowedHosts?: Readonly[]; + }, ): Promise { 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 = 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): 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; +} diff --git a/packages/platform-server/test/utils_spec.ts b/packages/platform-server/test/utils_spec.ts new file mode 100644 index 00000000000..8850b535743 --- /dev/null +++ b/packages/platform-server/test/utils_spec.ts @@ -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: '', + 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: '', + 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: '', + 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: '', + url: 'http://test.com/deep/path', + allowedHosts: ['test.com', '*.example.com'], + }); + } catch (error: any) { + expect(error.message).not.toContain('is not allowed'); + } + }); +});