Skip to content

Commit 60e7b81

Browse files
committed
fixup! fix(@angular/ssr): validate host headers to prevent header-based SSRF
1 parent 3c7678c commit 60e7b81

File tree

7 files changed

+122
-94
lines changed

7 files changed

+122
-94
lines changed

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@
5353
"additionalProperties": false,
5454
"properties": {
5555
"allowedHosts": {
56-
"description": "A list of hosts that are allowed to access the server-side application.",
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.",
5757
"type": "array",
5858
"uniqueItems": true,
5959
"items": {

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

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -86,11 +86,7 @@ export function generateAngularServerAppEngineManifest(
8686
const manifestContent = `
8787
export default {
8888
basePath: '${basePath}',
89-
allowedHosts: ${JSON.stringify(
90-
allowedHosts.map((host) => host.replace(/^www\./i, '')),
91-
undefined,
92-
2,
93-
)},
89+
allowedHosts: ${JSON.stringify(allowedHosts, undefined, 2)},
9490
supportedLocales: ${JSON.stringify(supportedLocales, undefined, 2)},
9591
entryPoints: {
9692
${Object.entries(entryPoints)

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

Lines changed: 7 additions & 32 deletions
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 { isHostAllowed } from '../../../src/utils/validation';
1516
import { attachNodeGlobalErrorHandlers } from '../errors';
1617
import { CommonEngineInlineCriticalCssProcessor } from './inline-css-processor';
1718
import {
@@ -20,11 +21,6 @@ import {
2021
runMethodAndMeasurePerf,
2122
} from './peformance-profiler';
2223

23-
/**
24-
* Regular expression to match and remove the `www.` prefix from hostnames.
25-
*/
26-
const WWW_HOST_REGEX = /^www\./i;
27-
2824
const SSG_MARKER_REGEXP = /ng-server-context=["']\w*\|?ssg\|?\w*["']/;
2925

3026
export interface CommonEngineOptions {
@@ -75,13 +71,7 @@ export class CommonEngine {
7571
private readonly allowedHosts: ReadonlySet<string>;
7672

7773
constructor(private options: CommonEngineOptions) {
78-
this.allowedHosts = new Set([
79-
...options.allowedHosts.map((host) => host.replace(WWW_HOST_REGEX, '')),
80-
'localhost',
81-
'127.0.0.1',
82-
'::1',
83-
'[::1]',
84-
]);
74+
this.allowedHosts = new Set(options.allowedHosts);
8575

8676
attachNodeGlobalErrorHandlers();
8777
}
@@ -128,27 +118,12 @@ export class CommonEngine {
128118
throw new Error(`URL "${url}" is invalid.`);
129119
}
130120

131-
const hostname = new URL(url).hostname.replace(WWW_HOST_REGEX, '');
132-
133-
if (this.allowedHosts.has(hostname)) {
134-
return;
135-
}
136-
137-
// Support wildcard hostnames.
138-
for (const allowedHost of this.allowedHosts) {
139-
if (!allowedHost.startsWith('*.')) {
140-
continue;
141-
}
142-
143-
const domain = allowedHost.slice(1);
144-
if (hostname.endsWith(domain)) {
145-
return;
146-
}
121+
const hostname = new URL(url).hostname;
122+
if (!isHostAllowed(hostname, this.allowedHosts)) {
123+
throw new Error(
124+
`Host ${hostname} is not allowed. Please provide a list of allowed hosts in the "allowedHosts" option.`,
125+
);
147126
}
148-
149-
throw new Error(
150-
`Host ${hostname} is not allowed. Please provide a list of allowed hosts in the "allowedHosts" option.`,
151-
);
152127
}
153128

154129
private inlineCriticalCss(html: string, opts: CommonEngineRenderOptions): Promise<string> {

packages/angular/ssr/node/src/request.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88

99
import type { IncomingHttpHeaders, IncomingMessage } from 'node:http';
1010
import type { Http2ServerRequest } from 'node:http2';
11-
import { getFirstHeaderValue } from '../../src/utils/headers';
11+
import { getFirstHeaderValue } from '../../src/utils/validation';
1212

1313
/**
1414
* A set containing all the pseudo-headers defined in the HTTP/2 specification.

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,8 @@ import type { AngularServerApp, getOrCreateAngularServerApp } from './app';
1010
import { Hooks } from './hooks';
1111
import { getPotentialLocaleIdFromUrl, getPreferredLocale } from './i18n';
1212
import { EntryPointExports, getAngularAppEngineManifest } from './manifest';
13-
import { validateHeaders } from './utils/headers';
1413
import { joinUrlParts } from './utils/url';
14+
import { validateRequest } from './utils/validation';
1515

1616
/**
1717
* Angular server application engine.
@@ -80,7 +80,7 @@ export class AngularAppEngine {
8080
*/
8181
async handle(request: Request, requestContext?: unknown): Promise<Response | null> {
8282
try {
83-
validateHeaders(request, this.allowedHosts);
83+
validateRequest(request, this.allowedHosts);
8484
} catch (error) {
8585
const body = error instanceof Error ? error.message : undefined;
8686

packages/angular/ssr/src/utils/headers.ts renamed to packages/angular/ssr/src/utils/validation.ts

Lines changed: 75 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,6 @@ const VALID_PORT_REGEX = /^\d+$/;
1616
*/
1717
const VALID_PROTO_REGEX = /^https?$/i;
1818

19-
/**
20-
* Regular expression to match and remove the `www.` prefix from hostnames.
21-
*/
22-
const WWW_HOST_REGEX = /^www\./i;
23-
2419
/**
2520
* Regular expression to match path separators.
2621
*/
@@ -58,29 +53,36 @@ export function getFirstHeaderValue(
5853
}
5954

6055
/**
61-
* Validates the headers of an incoming request.
62-
*
63-
* This function checks for the validity of critical headers such as `x-forwarded-host`,
64-
* `host`, `x-forwarded-port`, and `x-forwarded-proto`.
65-
* It ensures that the hostnames match the allowed hosts and that ports and protocols adhere to expected formats.
56+
* Validates a request.
6657
*
67-
* @param request - The incoming `Request` object containing the headers to validate.
58+
* @param request - The incoming `Request` object to validate.
6859
* @param allowedHosts - A set of allowed hostnames.
6960
* @throws Error if any of the validated headers contain invalid values.
7061
*/
71-
export function validateHeaders(request: Request, allowedHosts: ReadonlySet<string>): void {
72-
const headers = request.headers;
73-
validateHost('x-forwarded-host', headers, allowedHosts);
74-
validateHost('host', headers, allowedHosts);
62+
export function validateRequest(request: Request, allowedHosts: ReadonlySet<string>): void {
63+
validateHeaders(request, allowedHosts);
64+
validateUrl(new URL(request.url), allowedHosts);
65+
}
7566

76-
const xForwardedPort = getFirstHeaderValue(headers.get('x-forwarded-port'));
77-
if (xForwardedPort && !VALID_PORT_REGEX.test(xForwardedPort)) {
78-
throw new Error('Header "x-forwarded-port" must be a numeric value.');
79-
}
67+
/**
68+
* Validates that the hostname of a given URL is allowed.
69+
*
70+
* @param url - The URL object to validate.
71+
* @param allowedHosts - A set of allowed hostnames.
72+
* @throws Error if the hostname is not in the allowlist.
73+
*/
74+
export function validateUrl(url: URL, allowedHosts: ReadonlySet<string>): void {
75+
const { hostname } = url;
76+
if (!isHostAllowed(hostname, allowedHosts)) {
77+
let errorMessage = `URL with hostname "${hostname}" is not allowed.`;
78+
if (typeof ngDevMode === 'undefined' || ngDevMode) {
79+
errorMessage +=
80+
'\n\nAction Required: Update your "angular.json" to include this hostname. ' +
81+
'Path: "projects.[project-name].architect.build.options.security.allowedHosts".' +
82+
'\n\nFor more information, see https://angular.dev/guide/ssr#configuring-allowed-hosts';
83+
}
8084

81-
const xForwardedProto = getFirstHeaderValue(headers.get('x-forwarded-proto'));
82-
if (xForwardedProto && !VALID_PROTO_REGEX.test(xForwardedProto)) {
83-
throw new Error('Header "x-forwarded-proto" must be either "http" or "https".');
85+
throw new Error(errorMessage);
8486
}
8587
}
8688

@@ -92,12 +94,12 @@ export function validateHeaders(request: Request, allowedHosts: ReadonlySet<stri
9294
* @param allowedHosts - A set of allowed hostnames.
9395
* @throws Error if the header value is invalid or the hostname is not in the allowlist.
9496
*/
95-
function validateHost(
97+
function validateHostHeaders(
9698
headerName: string,
9799
headers: Headers,
98100
allowedHosts: ReadonlySet<string>,
99101
): void {
100-
const value = getFirstHeaderValue(headers.get(headerName))?.replace(WWW_HOST_REGEX, '');
102+
const value = getFirstHeaderValue(headers.get(headerName));
101103
if (!value) {
102104
return;
103105
}
@@ -113,25 +115,34 @@ function validateHost(
113115
}
114116

115117
const { hostname } = new URL(url);
116-
if (
118+
if (!isHostAllowed(hostname, allowedHosts)) {
119+
let errorMessage = `Header "${headerName}" with value "${value}" is not allowed.`;
120+
if (typeof ngDevMode === 'undefined' || ngDevMode) {
121+
errorMessage +=
122+
'\n\nAction Required: Update your "angular.json" to include this hostname. ' +
123+
'Path: "projects.[project-name].architect.build.options.security.allowedHosts".' +
124+
'\n\nFor more information, see https://angular.dev/guide/ssr#configuring-allowed-hosts';
125+
}
126+
127+
throw new Error(errorMessage);
128+
}
129+
}
130+
131+
/**
132+
* Checks if the hostname is allowed.
133+
* @param hostname - The hostname to check.
134+
* @param allowedHosts - A set of allowed hostnames.
135+
* @returns `true` if the hostname is allowed, `false` otherwise.
136+
*/
137+
export function isHostAllowed(hostname: string, allowedHosts: ReadonlySet<string>): boolean {
138+
return (
117139
// Check the provided allowed hosts first.
118140
allowedHosts.has(hostname) ||
119141
checkWildcardHostnames(hostname, allowedHosts) ||
120142
// Check the default allowed hosts last this is the fallback and should be rarely if ever used in production.
121143
DEFAULT_ALLOWED_HOSTS.has(hostname) ||
122144
checkWildcardHostnames(hostname, DEFAULT_ALLOWED_HOSTS)
123-
) {
124-
return;
125-
}
126-
127-
let errorMessage = `Header "${headerName}" with value "${value}" is not allowed.`;
128-
if (typeof ngDevMode === 'undefined' || ngDevMode) {
129-
errorMessage +=
130-
'\n\nAction Required: Update your "angular.json" to include this hostname. ' +
131-
'Path: "projects.[project-name].architect.build.options.security.allowedHosts".';
132-
}
133-
134-
throw new Error(errorMessage);
145+
);
135146
}
136147

137148
/**
@@ -154,3 +165,30 @@ function checkWildcardHostnames(hostname: string, allowedHosts: ReadonlySet<stri
154165

155166
return false;
156167
}
168+
169+
/**
170+
* Validates the headers of an incoming request.
171+
*
172+
* This function checks for the validity of critical headers such as `x-forwarded-host`,
173+
* `host`, `x-forwarded-port`, and `x-forwarded-proto`.
174+
* It ensures that the hostnames match the allowed hosts and that ports and protocols adhere to expected formats.
175+
*
176+
* @param request - The incoming `Request` object containing the headers to validate.
177+
* @param allowedHosts - A set of allowed hostnames.
178+
* @throws Error if any of the validated headers contain invalid values.
179+
*/
180+
function validateHeaders(request: Request, allowedHosts: ReadonlySet<string>): void {
181+
const headers = request.headers;
182+
validateHostHeaders('x-forwarded-host', headers, allowedHosts);
183+
validateHostHeaders('host', headers, allowedHosts);
184+
185+
const xForwardedPort = getFirstHeaderValue(headers.get('x-forwarded-port'));
186+
if (xForwardedPort && !VALID_PORT_REGEX.test(xForwardedPort)) {
187+
throw new Error('Header "x-forwarded-port" must be a numeric value.');
188+
}
189+
190+
const xForwardedProto = getFirstHeaderValue(headers.get('x-forwarded-proto'));
191+
if (xForwardedProto && !VALID_PROTO_REGEX.test(xForwardedProto)) {
192+
throw new Error('Header "x-forwarded-proto" must be either "http" or "https".');
193+
}
194+
}

0 commit comments

Comments
 (0)