Skip to content

Commit a44aef7

Browse files
committed
fix(@angular/ssr): validate host headers to prevent header-based SSRF
This change introduces strict validation for `Host`, `X-Forwarded-Host`, `X-Forwarded-Proto`, and `X-Forwarded-Port` headers in the Angular SSR request handling pipeline, including `CommonEngine` and `AngularAppEngine`. Previously, the application engine constructed the base URL for server-side rendering using these headers without validation. This could allow an attacker to manipulate the headers to steer relative `HttpClient` requests to arbitrary internal or external hosts (SSRF). With this change: - The `Host` and `X-Forwarded-Host` headers are validated against a strict allowlist. - `X-Forwarded-Port` must be numeric. - `X-Forwarded-Proto` must be `http` or `https`. - Requests with invalid or disallowed headers will now log an error and fallback to Client-Side Rendering (CSR). In a future major version, these requests will be rejected with a 400 Bad Request. Note: Most cloud providers and CDNs already validate these headers before the request reaches the text application, but this change adds an essential layer of defense-in-depth. **AngularAppEngine Users:** To exclude safe hosts from validation, configure the `allowedHosts` option in `angular.json` to include all domain names where your application is deployed. Example configuration in `angular.json`: ```json "architect": { "build": { "options": { "security": { "allowedHosts": ["example.com", "*.trusted-example.com"] } } } } ``` **CommonEngine Users:** If you are using `CommonEngine`, you must now provide the `allowedHosts` option when initializing or rendering your application. Example: ```typescript const commonEngine = new CommonEngine({ allowedHosts: ["example.com", "*.trusted-example.com"] }); ``` The application also respects `NG_ALLOWED_HOSTS` (comma-separated list) and `HOSTNAME` environment variables for authorizing hosts.
1 parent b0f49bb commit a44aef7

36 files changed

+758
-50
lines changed

goldens/public-api/angular/ssr/index.api.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,17 @@ import { Type } from '@angular/core';
1111

1212
// @public
1313
export class AngularAppEngine {
14+
constructor(options?: AngularAppEngineOptions);
1415
handle(request: Request, requestContext?: unknown): Promise<Response | null>;
1516
static ɵallowStaticRouteRender: boolean;
1617
static ɵhooks: Hooks;
1718
}
1819

20+
// @public
21+
export interface AngularAppEngineOptions {
22+
allowedHosts?: readonly string[];
23+
}
24+
1925
// @public
2026
export function createRequestHandler(handler: RequestHandlerFunction): RequestHandlerFunction;
2127

goldens/public-api/angular/ssr/node/index.api.md

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,18 +15,23 @@ import { Type } from '@angular/core';
1515

1616
// @public
1717
export class AngularNodeAppEngine {
18-
constructor();
19-
handle(request: IncomingMessage | Http2ServerRequest, requestContext?: unknown): Promise<Response | null>;
18+
constructor(options: AngularNodeAppEngineOptions);
19+
handle(request: IncomingMessage | Http2ServerRequest | Request, requestContext?: unknown): Promise<Response | null>;
20+
}
21+
22+
// @public
23+
export interface AngularNodeAppEngineOptions extends AngularAppEngineOptions {
2024
}
2125

2226
// @public
2327
export class CommonEngine {
24-
constructor(options?: CommonEngineOptions | undefined);
28+
constructor(options: CommonEngineOptions);
2529
render(opts: CommonEngineRenderOptions): Promise<string>;
2630
}
2731

2832
// @public (undocumented)
2933
export interface CommonEngineOptions {
34+
allowedHosts: readonly string[];
3035
bootstrap?: Type<{}> | ((context: BootstrapContext) => Promise<ApplicationRef>);
3136
enablePerformanceProfiler?: boolean;
3237
providers?: StaticProvider[];

packages/angular/build/src/builders/application/execute-build.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ export async function executeBuild(
5656
verbose,
5757
colors,
5858
jsonLogs,
59+
security,
5960
} = options;
6061

6162
// TODO: Consider integrating into watch mode. Would require full rebuild on target changes.
@@ -263,7 +264,7 @@ export async function executeBuild(
263264
if (serverEntryPoint) {
264265
executionResult.addOutputFile(
265266
SERVER_APP_ENGINE_MANIFEST_FILENAME,
266-
generateAngularServerAppEngineManifest(i18nOptions, baseHref),
267+
generateAngularServerAppEngineManifest(i18nOptions, security.allowedHosts, baseHref),
267268
BuildOutputFileType.ServerRoot,
268269
);
269270
}

packages/angular/build/src/builders/application/options.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -400,8 +400,9 @@ export async function normalizeOptions(
400400
}
401401
}
402402

403-
const autoCsp = options.security?.autoCsp;
403+
const { autoCsp, allowedHosts = [] } = options.security ?? {};
404404
const security = {
405+
allowedHosts,
405406
autoCsp: autoCsp
406407
? {
407408
unsafeEval: autoCsp === true ? false : !!autoCsp.unsafeEval,

packages/angular/build/src/builders/application/schema.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,14 @@
5252
"type": "object",
5353
"additionalProperties": false,
5454
"properties": {
55+
"allowedHosts": {
56+
"description": "A list of hostnames that are allowed to access the server-side application. For more information, see https://angular.dev/guide/ssr#configuring-allowed-hosts.",
57+
"type": "array",
58+
"uniqueItems": true,
59+
"items": {
60+
"type": "string"
61+
}
62+
},
5563
"autoCsp": {
5664
"description": "Enables automatic generation of a hash-based Strict Content Security Policy (https://web.dev/articles/strict-csp#choose-hash) based on scripts in index.html. Will default to true once we are out of experimental/preview phases.",
5765
"default": false,

packages/angular/build/src/builders/dev-server/vite/index.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@
99
import type { BuilderContext } from '@angular-devkit/architect';
1010
import type { Plugin } from 'esbuild';
1111
import assert from 'node:assert';
12-
import { builtinModules, isBuiltin } from 'node:module';
1312
import { join } from 'node:path';
1413
import type { Connect, ViteDevServer } from 'vite';
1514
import type { ComponentStyleRecord } from '../../../tools/vite/middlewares';
@@ -21,7 +20,6 @@ import { Result, ResultKind } from '../../application/results';
2120
import { OutputHashing } from '../../application/schema';
2221
import {
2322
type ApplicationBuilderInternalOptions,
24-
type ExternalResultMetadata,
2523
JavaScriptTransformer,
2624
getSupportedBrowsers,
2725
isZonelessApp,
@@ -99,8 +97,16 @@ export async function* serveWithVite(
9997
browserOptions.ssr ||= true;
10098
}
10199

102-
// Disable auto CSP.
100+
const allowedHosts = Array.isArray(serverOptions.allowedHosts)
101+
? [...serverOptions.allowedHosts]
102+
: [];
103+
104+
// Always allow the dev server host
105+
allowedHosts.push(serverOptions.host);
106+
103107
browserOptions.security = {
108+
allowedHosts,
109+
// Disable auto CSP.
104110
autoCsp: false,
105111
};
106112

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,11 +53,13 @@ function escapeUnsafeChars(str: string): string {
5353
*
5454
* @param i18nOptions - The internationalization options for the application build. This
5555
* includes settings for inlining locales and determining the output structure.
56+
* @param allowedHosts - A list of hosts that are allowed to access the server-side application.
5657
* @param baseHref - The base HREF for the application. This is used to set the base URL
5758
* for all relative URLs in the application.
5859
*/
5960
export function generateAngularServerAppEngineManifest(
6061
i18nOptions: NormalizedApplicationBuildOptions['i18nOptions'],
62+
allowedHosts: string[],
6163
baseHref: string | undefined,
6264
): string {
6365
const entryPoints: Record<string, string> = {};
@@ -84,6 +86,7 @@ export function generateAngularServerAppEngineManifest(
8486
const manifestContent = `
8587
export default {
8688
basePath: '${basePath}',
89+
allowedHosts: ${JSON.stringify(allowedHosts, undefined, 2)},
8790
supportedLocales: ${JSON.stringify(supportedLocales, undefined, 2)},
8891
entryPoints: {
8992
${Object.entries(entryPoints)

packages/angular/ssr/node/public_api.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ export {
1212
type CommonEngineOptions,
1313
} from './src/common-engine/common-engine';
1414

15-
export { AngularNodeAppEngine } from './src/app-engine';
15+
export { AngularNodeAppEngine, type AngularNodeAppEngineOptions } from './src/app-engine';
1616

1717
export { createNodeRequestHandler, type NodeRequestHandlerFunction } from './src/handler';
1818
export { writeResponseToNodeResponse } from './src/response';

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

Lines changed: 64 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,15 @@
99
import { AngularAppEngine } from '@angular/ssr';
1010
import type { IncomingMessage } from 'node:http';
1111
import type { Http2ServerRequest } from 'node:http2';
12+
import { AngularAppEngineOptions } from '../../src/app-engine';
1213
import { attachNodeGlobalErrorHandlers } from './errors';
1314
import { createWebRequestFromNodeRequest } from './request';
1415

16+
/**
17+
* Options for the Angular Node.js server application engine.
18+
*/
19+
export interface AngularNodeAppEngineOptions extends AngularAppEngineOptions {}
20+
1521
/**
1622
* Angular server application engine.
1723
* Manages Angular server applications (including localized ones), handles rendering requests,
@@ -21,32 +27,84 @@ import { createWebRequestFromNodeRequest } from './request';
2127
* application to ensure consistent handling of rendering requests and resource management.
2228
*/
2329
export class AngularNodeAppEngine {
24-
private readonly angularAppEngine = new AngularAppEngine();
30+
private readonly angularAppEngine: AngularAppEngine;
31+
32+
/**
33+
* Creates a new instance of the Angular Node.js server application engine.
34+
* @param options Options for the Angular Node.js server application engine.
35+
*/
36+
constructor(options: AngularNodeAppEngineOptions) {
37+
this.angularAppEngine = new AngularAppEngine(this.resolveAppEngineOptions(options));
2538

26-
constructor() {
2739
attachNodeGlobalErrorHandlers();
2840
}
2941

3042
/**
3143
* Handles an incoming HTTP request by serving prerendered content, performing server-side rendering,
3244
* or delivering a static file for client-side rendered routes based on the `RenderMode` setting.
3345
*
34-
* This method adapts Node.js's `IncomingMessage` or `Http2ServerRequest`
46+
* This method adapts Node.js's `IncomingMessage`, `Http2ServerRequest` or `Request`
3547
* to a format compatible with the `AngularAppEngine` and delegates the handling logic to it.
3648
*
37-
* @param request - The incoming HTTP request (`IncomingMessage` or `Http2ServerRequest`).
49+
* @param request - The incoming HTTP request (`IncomingMessage`, `Http2ServerRequest` or `Request`).
3850
* @param requestContext - Optional context for rendering, such as metadata associated with the request.
3951
* @returns A promise that resolves to the resulting HTTP response object, or `null` if no matching Angular route is found.
4052
*
4153
* @remarks A request to `https://www.example.com/page/index.html` will serve or render the Angular route
4254
* corresponding to `https://www.example.com/page`.
55+
*
56+
* @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_HOSTNAMES']`) or pass it directly
66+
* through the configuration options of `AngularNodeAppEngine`.
67+
*
68+
* For more information see: https://angular.dev/guide/ssr#configuring-allowed-hosts
4369
*/
4470
async handle(
45-
request: IncomingMessage | Http2ServerRequest,
71+
request: IncomingMessage | Http2ServerRequest | Request,
4672
requestContext?: unknown,
4773
): Promise<Response | null> {
48-
const webRequest = createWebRequestFromNodeRequest(request);
74+
const webRequest =
75+
request instanceof Request ? request : createWebRequestFromNodeRequest(request);
4976

5077
return this.angularAppEngine.handle(webRequest, requestContext);
5178
}
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(options: AngularNodeAppEngineOptions): AngularAppEngineOptions {
86+
const allowedHosts = options.allowedHosts ? [...options.allowedHosts] : [];
87+
const processEnv = process.env;
88+
89+
const envNgAllowedHosts = processEnv['NG_ALLOWED_HOSTS'];
90+
if (envNgAllowedHosts) {
91+
const hosts = envNgAllowedHosts.split(',');
92+
for (const host of hosts) {
93+
const hostTrimmed = host.trim();
94+
if (hostTrimmed) {
95+
allowedHosts.push(hostTrimmed);
96+
}
97+
}
98+
}
99+
100+
const envHostName = processEnv['HOSTNAME']?.trim();
101+
if (envHostName) {
102+
allowedHosts.push(envHostName);
103+
}
104+
105+
return {
106+
...options,
107+
allowedHosts,
108+
};
109+
}
52110
}

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

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { renderApplication, renderModule, ɵSERVER_CONTEXT } from '@angular/plat
1212
import * as fs from 'node:fs';
1313
import { dirname, join, normalize, resolve } from 'node:path';
1414
import { URL } from 'node:url';
15+
import { validateUrl } from '../../../src/utils/validation';
1516
import { attachNodeGlobalErrorHandlers } from '../errors';
1617
import { CommonEngineInlineCriticalCssProcessor } from './inline-css-processor';
1718
import {
@@ -31,6 +32,9 @@ export interface CommonEngineOptions {
3132

3233
/** Enable request performance profiling data collection and printing the results in the server console. */
3334
enablePerformanceProfiler?: boolean;
35+
36+
/** A set of hostnames that are allowed to access the server. */
37+
allowedHosts: readonly string[];
3438
}
3539

3640
export interface CommonEngineRenderOptions {
@@ -64,8 +68,11 @@ export class CommonEngine {
6468
private readonly templateCache = new Map<string, string>();
6569
private readonly inlineCriticalCssProcessor = new CommonEngineInlineCriticalCssProcessor();
6670
private readonly pageIsSSG = new Map<string, boolean>();
71+
private readonly allowedHosts: ReadonlySet<string>;
72+
73+
constructor(private options: CommonEngineOptions) {
74+
this.allowedHosts = this.resolveAllowedHosts(options);
6775

68-
constructor(private options?: CommonEngineOptions | undefined) {
6976
attachNodeGlobalErrorHandlers();
7077
}
7178

@@ -74,6 +81,25 @@ export class CommonEngine {
7481
* render options
7582
*/
7683
async render(opts: CommonEngineRenderOptions): Promise<string> {
84+
const { url } = opts;
85+
86+
if (url && URL.canParse(url)) {
87+
const urlObj = new URL(url);
88+
try {
89+
validateUrl(urlObj, this.allowedHosts);
90+
} catch (error) {
91+
let document = opts.document;
92+
if (!document && opts.documentFilePath) {
93+
document = await this.getDocument(opts.documentFilePath);
94+
}
95+
// eslint-disable-next-line no-console
96+
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',
99+
);
100+
}
101+
}
102+
77103
const enablePerformanceProfiler = this.options?.enablePerformanceProfiler;
78104

79105
const runMethod = enablePerformanceProfiler
@@ -186,6 +212,34 @@ export class CommonEngine {
186212

187213
return doc;
188214
}
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): 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+
}
189243
}
190244

191245
async function exists(path: fs.PathLike): Promise<boolean> {

0 commit comments

Comments
 (0)