Skip to content

Commit 106e1c2

Browse files
committed
fixup! fix(@angular/ssr): validate host headers to prevent header-based SSRF
1 parent 1c45e32 commit 106e1c2

16 files changed

+366
-257
lines changed

packages/angular/build/src/utils/server-rendering/prerender.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,9 @@ async function renderPages(
225225
hasSsrEntry: !!outputFilesForWorker['server.mjs'],
226226
} as RenderWorkerData,
227227
execArgv: workerExecArgv,
228+
env: {
229+
'NG_ALLOWED_HOSTS': 'localhost',
230+
},
228231
});
229232

230233
try {
@@ -337,6 +340,9 @@ async function getAllRoutes(
337340
hasSsrEntry: !!outputFilesForWorker['server.mjs'],
338341
} as RoutesExtractorWorkerData,
339342
execArgv: workerExecArgv,
343+
env: {
344+
'NG_ALLOWED_HOSTS': 'localhost',
345+
},
340346
});
341347

342348
try {

packages/angular/ssr/node/src/app-engine.ts

Lines changed: 18 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { AngularAppEngine } from '@angular/ssr';
1010
import type { IncomingMessage } from 'node:http';
1111
import type { Http2ServerRequest } from 'node:http2';
1212
import { AngularAppEngineOptions } from '../../src/app-engine';
13+
import { getAllowedHostsFromEnv } from './environment-options';
1314
import { attachNodeGlobalErrorHandlers } from './errors';
1415
import { createWebRequestFromNodeRequest } from './request';
1516

@@ -34,7 +35,10 @@ export class AngularNodeAppEngine {
3435
* @param options Options for the Angular Node.js server application engine.
3536
*/
3637
constructor(options?: AngularNodeAppEngineOptions) {
37-
this.angularAppEngine = new AngularAppEngine(this.resolveAppEngineOptions(options));
38+
this.angularAppEngine = new AngularAppEngine({
39+
...options,
40+
allowedHosts: [...getAllowedHostsFromEnv(), ...(options?.allowedHosts ?? [])],
41+
});
3842

3943
attachNodeGlobalErrorHandlers();
4044
}
@@ -52,20 +56,20 @@ export class AngularNodeAppEngine {
5256
*
5357
* @remarks A request to `https://www.example.com/page/index.html` will serve or render the Angular route
5458
* corresponding to `https://www.example.com/page`.
55-
*
59+
*
5660
* @remarks
57-
* To prevent potential Server-Side Request Forgery (SSRF), this function verifies the hostname
58-
* of the `request.url` against a list of authorized hosts.
59-
* If the hostname is not recognized, a Client-Side Rendered (CSR) version of the page is returned.
60-
61-
* Resolution:
62-
* Authorize your hostname by configuring `allowedHosts` in `angular.json` in:
63-
* `projects.[project-name].architect.build.options.security.allowedHosts`.
64-
* Alternatively, you can define the allowed hostname via environment variables
65-
* (`process.env['HOSTNAME']` or `process.env['NG_ALLOWED_HOSTS']`) or pass it directly
66-
* through the configuration options of `AngularNodeAppEngine`.
67-
*
68-
* For more information see: https://angular.dev/best-practices/security#preventing-server-side-request-forgery-ssrf
61+
* To prevent potential Server-Side Request Forgery (SSRF), this function verifies the hostname
62+
* of the `request.url` against a list of authorized hosts.
63+
* If the hostname is not recognized and `allowedHosts` is not empty, a Client-Side Rendered (CSR) version of the
64+
* page is returned otherwise a 400 Bad Request is returned.
65+
*
66+
* Resolution:
67+
* Authorize your hostname by configuring `allowedHosts` in `angular.json` in:
68+
* `projects.[project-name].architect.build.options.security.allowedHosts`.
69+
* Alternatively, you can define the allowed hostname via the environment variable `process.env['NG_ALLOWED_HOSTS']`
70+
* or pass it directly through the configuration options of `AngularNodeAppEngine`.
71+
*
72+
* For more information see: https://angular.dev/best-practices/security#preventing-server-side-request-forgery-ssrf
6973
*/
7074
async handle(
7175
request: IncomingMessage | Http2ServerRequest | Request,
@@ -76,37 +80,4 @@ export class AngularNodeAppEngine {
7680

7781
return this.angularAppEngine.handle(webRequest, requestContext);
7882
}
79-
80-
/**
81-
* Resolves the Angular server application engine options.
82-
* @param options Options for the Angular server application engine.
83-
* @returns Resolved options for the Angular server application engine.
84-
*/
85-
private resolveAppEngineOptions(
86-
options: AngularNodeAppEngineOptions | undefined,
87-
): AngularAppEngineOptions {
88-
const allowedHosts = options?.allowedHosts ? [...options.allowedHosts] : [];
89-
const processEnv = process.env;
90-
91-
const envNgAllowedHosts = processEnv['NG_ALLOWED_HOSTS'];
92-
if (envNgAllowedHosts) {
93-
const hosts = envNgAllowedHosts.split(',');
94-
for (const host of hosts) {
95-
const hostTrimmed = host.trim();
96-
if (hostTrimmed) {
97-
allowedHosts.push(hostTrimmed);
98-
}
99-
}
100-
}
101-
102-
const envHostName = processEnv['HOSTNAME']?.trim();
103-
if (envHostName) {
104-
allowedHosts.push(envHostName);
105-
}
106-
107-
return {
108-
...options,
109-
allowedHosts,
110-
};
111-
}
11283
}

packages/angular/ssr/node/src/common-engine/common-engine.ts

Lines changed: 8 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import * as fs from 'node:fs';
1313
import { dirname, join, normalize, resolve } from 'node:path';
1414
import { URL } from 'node:url';
1515
import { validateUrl } from '../../../src/utils/validation';
16+
import { getAllowedHostsFromEnv } from '../environment-options';
1617
import { attachNodeGlobalErrorHandlers } from '../errors';
1718
import { CommonEngineInlineCriticalCssProcessor } from './inline-css-processor';
1819
import {
@@ -71,7 +72,10 @@ export class CommonEngine {
7172
private readonly allowedHosts: ReadonlySet<string>;
7273

7374
constructor(private options?: CommonEngineOptions) {
74-
this.allowedHosts = this.resolveAllowedHosts(options);
75+
this.allowedHosts = new Set([
76+
...getAllowedHostsFromEnv(),
77+
...(this.options?.allowedHosts ?? []),
78+
]);
7579

7680
attachNodeGlobalErrorHandlers();
7781
}
@@ -94,8 +98,9 @@ export class CommonEngine {
9498
}
9599
// eslint-disable-next-line no-console
96100
console.error(
97-
`ERROR: Host ${urlObj.hostname} is not allowed. Please provide a list of allowed hosts in the "allowedHosts" option.`,
98-
'Fallbacking to client side rendering. This will become a 400 Bad Request in a future major version.\n',
101+
`ERROR: Host ${urlObj.hostname} is not allowed. ` +
102+
'Please provide a list of allowed hosts in the "allowedHosts" option in the "CommonEngine" constructor.',
103+
`Falling back to client side rendering for ${urlObj.href}. This will become a 400 Bad Request in a future major version.\n`,
99104
);
100105
}
101106
}
@@ -212,34 +217,6 @@ export class CommonEngine {
212217

213218
return doc;
214219
}
215-
216-
/**
217-
* Resolves the allowed hosts from the provided options and environment variables.
218-
* @param options Options for the common engine.
219-
* @returns A set of allowed hosts.
220-
*/
221-
private resolveAllowedHosts(options: CommonEngineOptions | undefined): ReadonlySet<string> {
222-
const allowedHosts = new Set(options?.allowedHosts ?? []);
223-
const processEnv = process.env;
224-
225-
const envNgAllowedHosts = processEnv['NG_ALLOWED_HOSTS'];
226-
if (envNgAllowedHosts) {
227-
const hosts = envNgAllowedHosts.split(',');
228-
for (const host of hosts) {
229-
const hostTrimmed = host.trim();
230-
if (hostTrimmed) {
231-
allowedHosts.add(hostTrimmed);
232-
}
233-
}
234-
}
235-
236-
const envHostName = processEnv['HOSTNAME']?.trim();
237-
if (envHostName) {
238-
allowedHosts.add(envHostName);
239-
}
240-
241-
return allowedHosts;
242-
}
243220
}
244221

245222
async function exists(path: fs.PathLike): Promise<boolean> {
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
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+
/**
10+
* Retrieves the list of allowed hosts from the environment variable `NG_ALLOWED_HOSTS`.
11+
* @returns An array of allowed hosts.
12+
*/
13+
export function getAllowedHostsFromEnv(): string[] {
14+
const allowedHosts: string[] = [];
15+
const envNgAllowedHosts = process.env['NG_ALLOWED_HOSTS'];
16+
if (!envNgAllowedHosts) {
17+
return allowedHosts;
18+
}
19+
20+
const hosts = envNgAllowedHosts.split(',');
21+
for (const host of hosts) {
22+
const trimmed = host.trim();
23+
if (trimmed.length > 0) {
24+
allowedHosts.push(trimmed);
25+
}
26+
}
27+
28+
return allowedHosts;
29+
}

packages/angular/ssr/src/app-engine.ts

Lines changed: 62 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import { Hooks } from './hooks';
1111
import { getPotentialLocaleIdFromUrl, getPreferredLocale } from './i18n';
1212
import { EntryPointExports, getAngularAppEngineManifest } from './manifest';
1313
import { joinUrlParts } from './utils/url';
14-
import { secureRequest, validateRequest } from './utils/validation';
14+
import { cloneRequestWithPatchedHeaders, validateRequest } from './utils/validation';
1515

1616
/**
1717
* Options for the Angular server application engine.
@@ -78,15 +78,7 @@ export class AngularAppEngine {
7878
* @param options Options for the Angular server application engine.
7979
*/
8080
constructor(options?: AngularAppEngineOptions) {
81-
const allowedHosts = new Set(this.manifest.allowedHosts);
82-
83-
if (options?.allowedHosts) {
84-
for (const host of options.allowedHosts) {
85-
allowedHosts.add(host);
86-
}
87-
}
88-
89-
this.allowedHosts = allowedHosts;
81+
this.allowedHosts = new Set([...(options?.allowedHosts ?? []), ...this.manifest.allowedHosts]);
9082
}
9183

9284
/**
@@ -100,44 +92,37 @@ export class AngularAppEngine {
10092
* @remarks A request to `https://www.example.com/page/index.html` will serve or render the Angular route
10193
* corresponding to `https://www.example.com/page`.
10294
*
103-
* @remarks
104-
* To prevent potential Server-Side Request Forgery (SSRF), this function verifies the hostname
105-
* of the `request.url` against a list of authorized hosts.
106-
* If the hostname is not recognized, a Client-Side Rendered (CSR) version of the page is returned.
107-
108-
* Resolution:
109-
* Authorize your hostname by configuring `allowedHosts` in `angular.json` in:
110-
* `projects.[project-name].architect.build.options.security.allowedHosts`.
111-
* Alternatively, you pass it directly through the configuration options of `AngularAppEngine`.
112-
*
113-
* For more information see: https://angular.dev/best-practices/security#preventing-server-side-request-forgery-ssrf
114-
*/
95+
* @remarks
96+
* To prevent potential Server-Side Request Forgery (SSRF), this function verifies the hostname
97+
* of the `request.url` against a list of authorized hosts.
98+
* If the hostname is not recognized and `allowedHosts` is not empty, a Client-Side Rendered (CSR) version of the
99+
* page is returned otherwise a 400 Bad Request is returned.
100+
* Resolution:
101+
* Authorize your hostname by configuring `allowedHosts` in `angular.json` in:
102+
* `projects.[project-name].architect.build.options.security.allowedHosts`.
103+
* Alternatively, you pass it directly through the configuration options of `AngularAppEngine`.
104+
*
105+
* For more information see: https://angular.dev/best-practices/security#preventing-server-side-request-forgery-ssrf
106+
*/
115107
async handle(request: Request, requestContext?: unknown): Promise<Response | null> {
116108
const allowedHost = this.allowedHosts;
117-
request = secureRequest(request, this.allowedHosts);
118109

119110
try {
120111
validateRequest(request, allowedHost);
121112
} catch (error) {
122-
const msg = error instanceof Error ? error.message : error;
123-
124-
// eslint-disable-next-line no-console
125-
console.error(
126-
`ERROR: Bad Request ("${request.url}").\n` +
127-
msg +
128-
'\nFallbacking to client side rendering. This will become a 400 Bad Request in a future major version.',
129-
);
130-
131-
// Fallback to CSR to avoid a breaking change.
132-
// TODO(alanagius): Return a 400 and remove this fallback in the next major version (v22).
133-
const serverApp = await this.getAngularServerAppForRequest(request);
134-
135-
return serverApp?.serveClientSidePage() ?? null;
113+
return this.handleValidationError(error as Error, request);
136114
}
137115

138-
const serverApp = await this.getAngularServerAppForRequest(request);
116+
// Clone request with patched headers to prevent unallowed host header access.
117+
const { request: securedRequest, onError: onHeaderValidationError } =
118+
cloneRequestWithPatchedHeaders(request, allowedHost);
119+
120+
const serverApp = await this.getAngularServerAppForRequest(securedRequest);
139121
if (serverApp) {
140-
return serverApp.handle(request, requestContext);
122+
return Promise.race([
123+
onHeaderValidationError.then((error) => this.handleValidationError(error, securedRequest)),
124+
serverApp.handle(securedRequest, requestContext),
125+
]);
141126
}
142127

143128
if (this.supportedLocales.length > 1) {
@@ -266,4 +251,42 @@ export class AngularAppEngine {
266251

267252
return this.getEntryPointExports(potentialLocale) ?? this.getEntryPointExports('');
268253
}
254+
255+
/**
256+
* Handles validation errors by logging the error and returning an appropriate response.
257+
*
258+
* @param error - The validation error to handle.
259+
* @param request - The HTTP request that caused the validation error.
260+
* @returns A promise that resolves to a `Response` object with a 400 status code if allowed hosts are configured,
261+
* or `null` if allowed hosts are not configured (in which case the request is served client-side).
262+
*/
263+
private async handleValidationError(error: Error, request: Request): Promise<Response | null> {
264+
const isAllowedHostConfigured = this.allowedHosts.size > 0;
265+
const errorMessage = error.message;
266+
267+
// eslint-disable-next-line no-console
268+
console.error(
269+
`ERROR: Bad Request ("${request.url}").\n` +
270+
errorMessage +
271+
(isAllowedHostConfigured
272+
? ''
273+
: '\nFallbacking to client side rendering. This will become a 400 Bad Request in a future major version.') +
274+
'\n\nFor more information, see https://angular.dev/best-practices/security#preventing-server-side-request-forgery-ssrf',
275+
);
276+
277+
if (isAllowedHostConfigured) {
278+
// Allowed hosts has been configured incorrectly, thus we can return a 400 bad request.
279+
return new Response(errorMessage, {
280+
status: 400,
281+
statusText: 'Bad Request',
282+
headers: { 'Content-Type': 'text/plain' },
283+
});
284+
}
285+
286+
// Fallback to CSR to avoid a breaking change.
287+
// TODO(alanagius): Return a 400 and remove this fallback in the next major version (v22).
288+
const serverApp = await this.getAngularServerAppForRequest(request);
289+
290+
return serverApp?.serveClientSidePage() ?? null;
291+
}
269292
}

0 commit comments

Comments
 (0)