Skip to content

Commit 757306a

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"] } } } } ``` or ```ts const appEngine = new AngularAppEngine({ allowedHosts: ["example.com", "*.trusted-example.com"] }) const appEngine = new AngularNodeAppEngine({ 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 757306a

34 files changed

+757
-43
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: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,12 @@ 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
@@ -27,6 +31,7 @@ export class CommonEngine {
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/build/src/utils/server-rendering/prerender.ts

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

@@ -336,6 +339,9 @@ async function getAllRoutes(
336339
outputMode,
337340
hasSsrEntry: !!outputFilesForWorker['server.mjs'],
338341
} as RoutesExtractorWorkerData,
342+
env: {
343+
NG_ALLOWED_HOSTS: 'localhost',
344+
},
339345
execArgv: workerExecArgv,
340346
});
341347

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: 66 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,86 @@ 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_HOSTS']`) 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(
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+
}
52112
}

0 commit comments

Comments
 (0)