mirror of
https://github.com/angular/angular.git
synced 2026-09-14 13:54:52 +08:00
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:
committed by
Matthew Beck (Berry)
parent
103f0dddc4
commit
8569db8875
@@ -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';
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user