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
This commit is contained in:
Alan Agius
2026-05-06 13:03:40 +00:00
committed by Matthew Beck (Berry)
parent 103f0dddc4
commit 8569db8875
8 changed files with 182 additions and 20 deletions
@@ -50,6 +50,7 @@ export function renderApplication(bootstrap: (context: BootstrapContext) => Prom
document?: string | Document;
url?: string;
platformProviders?: Provider[];
allowedHosts?: Readonly<string>[];
}): Promise<string>;
// @public
@@ -57,6 +58,7 @@ export function renderModule<T>(moduleType: Type<T>, options: {
document?: string | Document;
url?: string;
extraProviders?: StaticProvider[];
allowedHosts?: Readonly<string>[];
}): Promise<string>;
// @public
@@ -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",
# )
@@ -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) => {
@@ -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) => {
@@ -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) => {
@@ -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';
+66 -8
View File
@@ -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,6 +327,7 @@ 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.
*
@@ -322,11 +335,19 @@ export async function renderModule<T>(
*/
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;
}
@@ -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');
}
});
});