Skip to content

Commit f584840

Browse files
alan-agius4mattrbeck
authored andcommitted
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
1 parent 4aee744 commit f584840

7 files changed

Lines changed: 171 additions & 8 deletions

File tree

goldens/public-api/platform-server/index.api.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,13 +50,15 @@ export function renderApplication(bootstrap: (context: BootstrapContext) => Prom
5050
document?: string | Document;
5151
url?: string;
5252
platformProviders?: Provider[];
53+
allowedHosts?: Readonly<string>[];
5354
}): Promise<string>;
5455

5556
// @public
5657
export function renderModule<T>(moduleType: Type<T>, options: {
5758
document?: string | Document;
5859
url?: string;
5960
extraProviders?: StaticProvider[];
61+
allowedHosts?: Readonly<string>[];
6062
}): Promise<string>;
6163

6264
// @public

integration/platform-server-zoneless/projects/standalone/server.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ app.use((req, res) => {
4242

4343
renderApplication(bootstrap, {
4444
document: indexHtml,
45+
allowedHosts: ['localhost'],
4546
url: `${protocol}://${headers.host}${originalUrl}`,
4647
platformProviders: [{provide: APP_BASE_HREF, useValue: baseUrl}],
4748
}).then((response: string) => {

integration/platform-server/projects/ngmodule/server.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ app.use((req, res) => {
4242

4343
renderModule(AppServerModule, {
4444
document: indexHtml,
45+
allowedHosts: ['localhost'],
4546
url: `${protocol}://${headers.host}${originalUrl}`,
4647
extraProviders: [{provide: APP_BASE_HREF, useValue: baseUrl}],
4748
}).then((response: string) => {

integration/platform-server/projects/standalone/server.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ app.use((req, res) => {
4242

4343
renderApplication(bootstrap, {
4444
document: indexHtml,
45+
allowedHosts: ['localhost'],
4546
url: `${protocol}://${headers.host}${originalUrl}`,
4647
platformProviders: [{provide: APP_BASE_HREF, useValue: baseUrl}],
4748
}).then((response: string) => {

packages/platform-server/src/private_export.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,6 @@ export {
1313
export {SERVER_CONTEXT as ɵSERVER_CONTEXT, renderInternal as ɵrenderInternal} from './utils';
1414
export {ENABLE_DOM_EMULATION as ɵENABLE_DOM_EMULATION} from './tokens';
1515
export {DominoAdapter as ɵDominoAdapter} from './domino_adapter';
16+
17+
// Use in @angular/ssr.
18+
export {isHostAllowed as ɵisHostAllowed} from './utils';

packages/platform-server/src/utils.ts

Lines changed: 66 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ import {BootstrapContext} from '@angular/platform-browser';
2626

2727
import {platformServer} from './server';
2828
import {PlatformState} from './platform_state';
29-
import {BEFORE_APP_SERIALIZED, INITIAL_CONFIG} from './tokens';
29+
import {BEFORE_APP_SERIALIZED, INITIAL_CONFIG, PlatformConfig} from './tokens';
3030
import {createScript} from './transfer_state';
3131

3232
/**
@@ -39,9 +39,8 @@ import {createScript} from './transfer_state';
3939
*/
4040
export const EVENT_DISPATCH_SCRIPT_ID = 'ng-event-dispatch-contract';
4141

42-
interface PlatformOptions {
42+
interface PlatformOptions extends Omit<PlatformConfig, 'document'> {
4343
document?: string | Document;
44-
url?: string;
4544
platformProviders?: Provider[];
4645
}
4746

@@ -53,9 +52,16 @@ function createServerPlatform(options: PlatformOptions): PlatformRef {
5352
const extraProviders = options.platformProviders ?? [];
5453
const measuringLabel = 'createServerPlatform';
5554
startMeasuring(measuringLabel);
55+
const {document, url} = options;
5656

5757
const platform = platformServer([
58-
{provide: INITIAL_CONFIG, useValue: {document: options.document, url: options.url}},
58+
{
59+
provide: INITIAL_CONFIG,
60+
useValue: {
61+
document,
62+
url,
63+
},
64+
},
5965
extraProviders,
6066
]);
6167

@@ -265,14 +271,20 @@ function sanitizeServerContext(serverContext: string): string {
265271
* as a reference to the `document` instance.
266272
* - `url` - the URL for the current render request.
267273
* - `extraProviders` - set of platform level providers for the current render request.
268-
*
274+
* - `allowedHosts` - the allowed hosts list for host validation in server-side rendering.
269275
* @publicApi
270276
*/
271277
export async function renderModule<T>(
272278
moduleType: Type<T>,
273-
options: {document?: string | Document; url?: string; extraProviders?: StaticProvider[]},
279+
options: {
280+
document?: string | Document;
281+
url?: string;
282+
extraProviders?: StaticProvider[];
283+
allowedHosts?: Readonly<string>[];
284+
},
274285
): Promise<string> {
275-
const {document, url, extraProviders: platformProviders} = options;
286+
const {document, url, extraProviders: platformProviders, allowedHosts} = options;
287+
validateAllowedHosts(url, allowedHosts);
276288
const platformRef = createServerPlatform({document, url, platformProviders});
277289
try {
278290
const moduleRef = await platformRef.bootstrapModule(moduleType);
@@ -315,18 +327,27 @@ export async function renderModule<T>(
315327
* as a reference to the `document` instance.
316328
* - `url` - the URL for the current render request.
317329
* - `platformProviders` - the platform level providers for the current render request.
330+
* - `allowedHosts` - the allowed hosts list for host validation in server-side rendering.
318331
*
319332
* @returns A Promise, that returns serialized (to a string) rendered page, once resolved.
320333
*
321334
* @publicApi
322335
*/
323336
export async function renderApplication(
324337
bootstrap: (context: BootstrapContext) => Promise<ApplicationRef>,
325-
options: {document?: string | Document; url?: string; platformProviders?: Provider[]},
338+
options: {
339+
document?: string | Document;
340+
url?: string;
341+
platformProviders?: Provider[];
342+
allowedHosts?: Readonly<string>[];
343+
},
326344
): Promise<string> {
327345
const renderAppLabel = 'renderApplication';
328346
const bootstrapLabel = 'bootstrap';
329347
const _renderLabel = '_render';
348+
const {url, allowedHosts} = options;
349+
350+
validateAllowedHosts(url, allowedHosts);
330351

331352
startMeasuring(renderAppLabel);
332353
const platformRef = createServerPlatform(options);
@@ -351,3 +372,40 @@ export async function renderApplication(
351372
stopMeasuring(renderAppLabel);
352373
}
353374
}
375+
376+
function validateAllowedHosts(url: string | undefined, allowedHosts: string[] | undefined) {
377+
if (typeof url === 'string' && URL.canParse(url)) {
378+
const hostname = new URL(url).hostname;
379+
const allowedHostsSet: ReadonlySet<string> = new Set(allowedHosts);
380+
if (!isHostAllowed(hostname, allowedHostsSet)) {
381+
throw new Error(`Host ${url} is not allowed. You can configure \`allowedHosts\` option.`);
382+
}
383+
}
384+
}
385+
386+
/**
387+
* Checks if the hostname is allowed.
388+
* @param hostname - The hostname to check.
389+
* @param allowedHosts - A set of allowed hostnames.
390+
* @returns `true` if the hostname is allowed, `false` otherwise.
391+
* @note Used also in `@angular/ssr`.
392+
* @private
393+
*/
394+
export function isHostAllowed(hostname: string, allowedHosts: ReadonlySet<string>): boolean {
395+
if (allowedHosts.has('*') || allowedHosts.has(hostname)) {
396+
return true;
397+
}
398+
399+
for (const allowedHost of allowedHosts) {
400+
if (!allowedHost.startsWith('*.')) {
401+
continue;
402+
}
403+
404+
const domain = allowedHost.slice(1);
405+
if (hostname.endsWith(domain)) {
406+
return true;
407+
}
408+
}
409+
410+
return false;
411+
}
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
9+
import {destroyPlatform} from '@angular/core';
10+
import {renderApplication, renderModule} from '@angular/platform-server';
11+
import {isHostAllowed} from '../src/utils';
12+
13+
describe('isHostAllowed', () => {
14+
it('allows matching hostname when in allowedHosts list', () => {
15+
expect(isHostAllowed('test.com', new Set(['test.com', 'example.com']))).toBeTrue();
16+
});
17+
18+
it('allows matching hostname when wildcard matches', () => {
19+
expect(isHostAllowed('sub.example.com', new Set(['test.com', '*.example.com']))).toBeTrue();
20+
});
21+
22+
it('rejects hostname when not in allowedHosts list', () => {
23+
expect(isHostAllowed('evil.com', new Set(['test.com', '*.example.com']))).toBeFalse();
24+
});
25+
26+
it('allows all hostnames when * is in allowedHosts list', () => {
27+
expect(isHostAllowed('anydomain.com', new Set(['*']))).toBeTrue();
28+
});
29+
});
30+
31+
describe('allowedHosts validation in renderApplication', () => {
32+
const bootstrap = (async () => {}) as any;
33+
34+
beforeEach(() => {
35+
destroyPlatform();
36+
});
37+
38+
afterEach(() => {
39+
destroyPlatform();
40+
});
41+
42+
it('should throw an error on bootstrap if host is not allowed', async () => {
43+
await expectAsync(
44+
renderApplication(bootstrap, {
45+
document: '<app></app>',
46+
url: 'http://evil.com/deep/path',
47+
allowedHosts: ['test.com', '*.example.com'],
48+
}),
49+
).toBeRejectedWithError(/Host http:\/\/evil.com\/deep\/path is not allowed/);
50+
});
51+
52+
it('should not throw a host validation error on bootstrap if host is allowed', async () => {
53+
try {
54+
await renderApplication(bootstrap, {
55+
document: '<app></app>',
56+
url: 'http://test.com/deep/path',
57+
allowedHosts: ['test.com', '*.example.com'],
58+
});
59+
} catch (error: any) {
60+
expect(error.message).not.toContain('is not allowed');
61+
}
62+
});
63+
});
64+
65+
describe('allowedHosts validation in renderModule', () => {
66+
class MockModule {}
67+
68+
beforeEach(() => {
69+
destroyPlatform();
70+
});
71+
72+
afterEach(() => {
73+
destroyPlatform();
74+
});
75+
76+
it('should throw an error if host is not allowed', async () => {
77+
await expectAsync(
78+
renderModule(MockModule, {
79+
document: '<app></app>',
80+
url: 'http://evil.com/deep/path',
81+
allowedHosts: ['test.com', '*.example.com'],
82+
}),
83+
).toBeRejectedWithError(/Host http:\/\/evil.com\/deep\/path is not allowed/);
84+
});
85+
86+
it('should not throw a host validation error if host is allowed', async () => {
87+
try {
88+
await renderModule(MockModule, {
89+
document: '<app></app>',
90+
url: 'http://test.com/deep/path',
91+
allowedHosts: ['test.com', '*.example.com'],
92+
});
93+
} catch (error: any) {
94+
expect(error.message).not.toContain('is not allowed');
95+
}
96+
});
97+
});

0 commit comments

Comments
 (0)