Skip to content

Commit 6e21510

Browse files
committed
fixup! fix(@angular/ssr): enforce explicit opt-in for proxy headers
1 parent a0aa5ba commit 6e21510

3 files changed

Lines changed: 89 additions & 72 deletions

File tree

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

Lines changed: 14 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,11 @@
88

99
import type { IncomingHttpHeaders, IncomingMessage } from 'node:http';
1010
import type { Http2ServerRequest } from 'node:http2';
11-
import { getFirstHeaderValue } from '../../src/utils/validation';
11+
import {
12+
getFirstHeaderValue,
13+
isProxyHeaderAllowed,
14+
normalizeTrustProxyHeaders,
15+
} from '../../src/utils/validation';
1216

1317
/**
1418
* A set containing all the pseudo-headers defined in the HTTP/2 specification.
@@ -48,11 +52,7 @@ export function createWebRequestFromNodeRequest(
4852
nodeRequest: IncomingMessage | Http2ServerRequest,
4953
trustProxyHeaders?: boolean | readonly string[],
5054
): Request {
51-
const trustProxyHeadersNormalized =
52-
trustProxyHeaders && typeof trustProxyHeaders !== 'boolean'
53-
? new Set(trustProxyHeaders.map((h) => h.toLowerCase()))
54-
: trustProxyHeaders;
55-
55+
const trustProxyHeadersNormalized = normalizeTrustProxyHeaders(trustProxyHeaders);
5656
const { headers, method = 'GET' } = nodeRequest;
5757
const withBody = method !== 'GET' && method !== 'HEAD';
5858
const referrer = headers.referer && URL.canParse(headers.referer) ? headers.referer : undefined;
@@ -70,12 +70,12 @@ export function createWebRequestFromNodeRequest(
7070
* Creates a `Headers` object from Node.js `IncomingHttpHeaders`.
7171
*
7272
* @param nodeHeaders - The Node.js `IncomingHttpHeaders` object to convert.
73-
* @param trustProxyHeaders - A boolean or a set of allowed proxy headers.
73+
* @param trustProxyHeaders - A set of allowed proxy headers.
7474
* @returns A `Headers` object containing the converted headers.
7575
*/
7676
function createRequestHeaders(
7777
nodeHeaders: IncomingHttpHeaders,
78-
trustProxyHeaders: boolean | ReadonlySet<string> | undefined,
78+
trustProxyHeaders: ReadonlySet<string>,
7979
): Headers {
8080
const headers = new Headers();
8181

@@ -88,6 +88,8 @@ function createRequestHeaders(
8888
name.toLowerCase().startsWith('x-forwarded-') &&
8989
!isProxyHeaderAllowed(name, trustProxyHeaders)
9090
) {
91+
// eslint-disable-next-line no-console
92+
console.warn(`Received "${name}" header but "trustProxyHeaders" was not set up to allow it.`);
9193
continue;
9294
}
9395

@@ -107,7 +109,7 @@ function createRequestHeaders(
107109
* Creates a `URL` object from a Node.js `IncomingMessage`, taking into account the protocol, host, and port.
108110
*
109111
* @param nodeRequest - The Node.js `IncomingMessage` or `Http2ServerRequest` object to extract URL information from.
110-
* @param trustProxyHeaders - A boolean or a set of allowed proxy headers.
112+
* @param trustProxyHeaders - A set of allowed proxy headers.
111113
*
112114
* @remarks
113115
* When `trustProxyHeaders` is enabled, headers such as `X-Forwarded-Host` and
@@ -118,7 +120,7 @@ function createRequestHeaders(
118120
*/
119121
export function createRequestUrl(
120122
nodeRequest: IncomingMessage | Http2ServerRequest,
121-
trustProxyHeaders?: boolean | ReadonlySet<string>,
123+
trustProxyHeaders: ReadonlySet<string>,
122124
): URL {
123125
const {
124126
headers,
@@ -156,37 +158,15 @@ export function createRequestUrl(
156158
*
157159
* @param headers - The Node.js incoming HTTP headers.
158160
* @param headerName - The name of the proxy header to retrieve.
159-
* @param trustProxyHeaders - A boolean or a set of allowed proxy headers.
161+
* @param trustProxyHeaders - A set of allowed proxy headers.
160162
* @returns The value of the allowed proxy header, or `undefined` if not allowed or not present.
161163
*/
162164
function getAllowedProxyHeaderValue(
163165
headers: IncomingHttpHeaders,
164166
headerName: string,
165-
trustProxyHeaders: boolean | ReadonlySet<string> | undefined,
167+
trustProxyHeaders: ReadonlySet<string>,
166168
): string | undefined {
167169
return isProxyHeaderAllowed(headerName, trustProxyHeaders)
168170
? getFirstHeaderValue(headers[headerName])
169171
: undefined;
170172
}
171-
172-
/**
173-
* Checks if a specific proxy header is allowed.
174-
*
175-
* @param headerName - The name of the proxy header to check.
176-
* @param trustProxyHeaders - A boolean or a set of allowed proxy headers.
177-
* @returns `true` if the header is allowed, `false` otherwise.
178-
*/
179-
function isProxyHeaderAllowed(
180-
headerName: string,
181-
trustProxyHeaders: boolean | ReadonlySet<string> | undefined,
182-
): boolean {
183-
if (!trustProxyHeaders) {
184-
return false;
185-
}
186-
187-
if (trustProxyHeaders === true) {
188-
return true;
189-
}
190-
191-
return trustProxyHeaders.has(headerName.toLowerCase());
192-
}

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

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,11 @@ import { getPotentialLocaleIdFromUrl, getPreferredLocale } from './i18n';
1212
import { EntryPointExports, getAngularAppEngineManifest } from './manifest';
1313
import { createRedirectResponse } from './utils/redirect';
1414
import { joinUrlParts } from './utils/url';
15-
import { sanitizeRequestHeaders, validateRequest } from './utils/validation';
15+
import {
16+
normalizeTrustProxyHeaders,
17+
sanitizeRequestHeaders,
18+
validateRequest,
19+
} from './utils/validation';
1620

1721
/**
1822
* Options for the Angular server application engine.
@@ -27,9 +31,12 @@ export interface AngularAppEngineOptions {
2731
* Extends the scope of trusted proxy headers (`X-Forwarded-*`).
2832
*
2933
* @remarks
30-
* When `trustProxyHeaders` is enabled, headers such as `X-Forwarded-Host` and
31-
* `X-Forwarded-Prefix` should ideally be strictly validated at a higher infrastructure
32-
* level (e.g., at the reverse proxy or API gateway) before reaching the application.
34+
* **This is a security-sensitive option!**
35+
*
36+
* When `trustProxyHeaders` is enabled, request headers such as `X-Forwarded-Host` and
37+
* `X-Forwarded-Prefix` are trusted by the server and used for routing. These
38+
* headers must be strictly validated and provided by a trusted client (e.g., at a reverse proxy, load
39+
* balancer, or API gateway) and must *not* be provided by untrusted end users.
3340
*
3441
* If a `string[]` is provided, only those proxy headers are allowed.
3542
* If `true`, all proxy headers are allowed.
@@ -97,7 +104,7 @@ export class AngularAppEngine {
97104
/**
98105
* The normalized allowed proxy headers.
99106
*/
100-
private readonly trustProxyHeaders: ReadonlySet<string> | boolean;
107+
private readonly trustProxyHeaders: ReadonlySet<string>;
101108

102109
/**
103110
* A cache that holds entry points, keyed by their potential locale string.
@@ -110,12 +117,7 @@ export class AngularAppEngine {
110117
*/
111118
constructor(options?: AngularAppEngineOptions) {
112119
this.allowedHosts = this.getAllowedHosts(options);
113-
114-
const trustProxyHeaders = options?.trustProxyHeaders ?? false;
115-
this.trustProxyHeaders =
116-
typeof trustProxyHeaders === 'boolean'
117-
? trustProxyHeaders
118-
: new Set(trustProxyHeaders.map((h) => h.toLowerCase()));
120+
this.trustProxyHeaders = normalizeTrustProxyHeaders(options?.trustProxyHeaders);
119121
}
120122

121123
private getAllowedHosts(options: AngularAppEngineOptions | undefined): ReadonlySet<string> {

packages/angular/ssr/src/utils/validation.ts

Lines changed: 62 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,17 @@
66
* found in the LICENSE file at https://angular.dev/license
77
*/
88

9+
/**
10+
* Common X-Forwarded-* headers.
11+
*/
12+
const X_FORWARDED_HEADERS = new Set([
13+
'x-forwarded-for',
14+
'x-forwarded-host',
15+
'x-forwarded-port',
16+
'x-forwarded-proto',
17+
'x-forwarded-prefix',
18+
]);
19+
920
/**
1021
* The set of headers that should be validated for host header injection attacks.
1122
*/
@@ -22,7 +33,8 @@ const VALID_PORT_REGEX = /^\d+$/;
2233
const VALID_PROTO_REGEX = /^https?$/i;
2334

2435
/**
25-
* Regular expression to validate that the prefix is valid.
36+
* Regular expression to validate that the prefix is valid. Validates that the prefix is a valid path prefix and nothing else.
37+
* It validates that the prefix starts with a forward slash and contains only letters, numbers, hyphens, underscores and forward slashes.
2638
*/
2739
const VALID_PREFIX_REGEX = /^\/([a-z0-9_-]+\/)*[a-z0-9_-]*$/i;
2840

@@ -85,42 +97,32 @@ export function validateUrl(url: URL, allowedHosts: ReadonlySet<string>): void {
8597
* If no headers need to be removed, the original request is returned without cloning.
8698
*
8799
* @param request - The incoming `Request` object to sanitize.
88-
* @param trustProxyHeaders - A set of allowed proxy headers or a boolean indicating whether all are allowed.
100+
* @param trustProxyHeaders - A set of allowed proxy headers.
89101
* @returns The sanitized request, or the original request if no changes were needed.
90102
*/
91103
export function sanitizeRequestHeaders(
92104
request: Request,
93-
trustProxyHeaders?: boolean | ReadonlySet<string>,
105+
trustProxyHeaders: ReadonlySet<string>,
94106
): Request {
95-
if (trustProxyHeaders === true) {
96-
return request;
97-
}
107+
let headersDeleted = false;
108+
const headers = new Headers();
98109

99-
const keysToDelete: string[] = [];
100-
for (const [key] of request.headers) {
110+
for (const [key, value] of request.headers) {
101111
const lowerKey = key.toLowerCase();
102-
if (
103-
lowerKey.startsWith('x-forwarded-') &&
104-
(!trustProxyHeaders || !trustProxyHeaders.has(lowerKey))
105-
) {
106-
keysToDelete.push(key);
112+
if (lowerKey.startsWith('x-forwarded-') && !isProxyHeaderAllowed(lowerKey, trustProxyHeaders)) {
113+
console.warn(`Received "${key}" header but "trustProxyHeaders" was not set up to allow it.`);
114+
headersDeleted = true;
115+
} else {
116+
headers.set(key, value);
107117
}
108118
}
109119

110-
if (keysToDelete.length === 0) {
111-
return request;
112-
}
113-
114-
const clonedReq = new Request(request.clone(), {
115-
signal: request.signal,
116-
});
117-
118-
const headers = clonedReq.headers;
119-
for (const key of keysToDelete) {
120-
headers.delete(key);
121-
}
122-
123-
return clonedReq;
120+
return headersDeleted
121+
? new Request(request.clone(), {
122+
signal: request.signal,
123+
headers,
124+
})
125+
: request;
124126
}
125127

126128
/**
@@ -217,3 +219,36 @@ function validateHeaders(
217219
);
218220
}
219221
}
222+
223+
/**
224+
* Checks if a specific proxy header is allowed.
225+
*
226+
* @param headerName - The name of the proxy header to check.
227+
* @param trustProxyHeaders - A set of allowed proxy headers.
228+
* @returns `true` if the header is allowed, `false` otherwise.
229+
*/
230+
export function isProxyHeaderAllowed(
231+
headerName: string,
232+
trustProxyHeaders: ReadonlySet<string>,
233+
): boolean {
234+
return trustProxyHeaders.has(headerName.toLowerCase());
235+
}
236+
237+
/**
238+
* Normalizes the `trustProxyHeaders` option to a consistent representation.
239+
* @param trustProxyHeaders The input `trustProxyHeaders` value.
240+
* @returns A `Set<string>` of normalized header names.
241+
*/
242+
export function normalizeTrustProxyHeaders(
243+
trustProxyHeaders: boolean | readonly string[] | undefined,
244+
): ReadonlySet<string> {
245+
if (!trustProxyHeaders) {
246+
return new Set();
247+
}
248+
249+
if (trustProxyHeaders === true) {
250+
return X_FORWARDED_HEADERS;
251+
}
252+
253+
return new Set(trustProxyHeaders.map((h) => h.toLowerCase()));
254+
}

0 commit comments

Comments
 (0)