Skip to content

Commit 67582a9

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`.
1 parent 750f037 commit 67582a9

36 files changed

+960
-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/best-practices/security#preventing-server-side-request-forgery-ssrf.",
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-server.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,16 @@ export async function* serveWithVite(
113113
}
114114

115115
// Disable auto CSP.
116+
const allowedHosts = Array.isArray(serverOptions.allowedHosts)
117+
? [...serverOptions.allowedHosts]
118+
: [];
119+
120+
// Always allow the dev server host
121+
allowedHosts.push(serverOptions.host);
122+
116123
browserOptions.security = {
124+
allowedHosts,
125+
// Disable auto CSP.
117126
autoCsp: false,
118127
};
119128

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
@@ -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/BUILD.bazel

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ ts_project(
2020
),
2121
args = [
2222
"--lib",
23-
"dom,es2020",
23+
"dom.iterable,dom,es2022",
2424
],
2525
data = [
2626
"//packages/angular/ssr/third_party/beasties:beasties_bundled",

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';

0 commit comments

Comments
 (0)