Skip to content

Commit 4f4a50c

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

4 files changed

Lines changed: 96 additions & 75 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: 63 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,33 @@ 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+
// eslint-disable-next-line no-console
114+
console.warn(`Received "${key}" header but "trustProxyHeaders" was not set up to allow it.`);
115+
headersDeleted = true;
116+
} else {
117+
headers.set(key, value);
107118
}
108119
}
109120

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;
121+
return headersDeleted
122+
? new Request(request.clone(), {
123+
signal: request.signal,
124+
headers,
125+
})
126+
: request;
124127
}
125128

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

packages/angular/ssr/test/utils/validation_spec.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -224,7 +224,7 @@ describe('Validation Utils', () => {
224224
'x-forwarded-proto': 'https',
225225
},
226226
});
227-
const secured = sanitizeRequestHeaders(req);
227+
const secured = sanitizeRequestHeaders(req, new Set());
228228

229229
expect(secured.headers.get('host')).toBe('example.com');
230230
expect(secured.headers.has('x-forwarded-host')).toBeFalse();
@@ -255,7 +255,10 @@ describe('Validation Utils', () => {
255255
'x-forwarded-proto': 'https',
256256
},
257257
});
258-
const secured = sanitizeRequestHeaders(req, true);
258+
const secured = sanitizeRequestHeaders(
259+
req,
260+
new Set(['x-forwarded-host', 'x-forwarded-proto']),
261+
);
259262

260263
expect(secured.headers.get('host')).toBe('example.com');
261264
expect(secured.headers.get('x-forwarded-host')).toBe('proxy.com');
@@ -266,7 +269,7 @@ describe('Validation Utils', () => {
266269
const req = new Request('http://example.com', {
267270
headers: { 'accept': 'application/json' },
268271
});
269-
const secured = sanitizeRequestHeaders(req);
272+
const secured = sanitizeRequestHeaders(req, new Set());
270273

271274
expect(secured).toBe(req);
272275
expect(secured.headers.get('accept')).toBe('application/json');

0 commit comments

Comments
 (0)