Skip to content

Commit fdda2b1

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

4 files changed

Lines changed: 98 additions & 89 deletions

File tree

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

Lines changed: 13 additions & 48 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.
@@ -33,33 +37,27 @@ const HTTP2_PSEUDO_HEADERS: ReadonlySet<string> = new Set([
3337
* be used by web platform APIs.
3438
*
3539
* @param nodeRequest - The Node.js request object (`IncomingMessage` or `Http2ServerRequest`) to convert.
36-
* @param trustProxyHeaders - A boolean or an array of allowed proxy headers.
40+
* @param trustProxyHeaders - A boolean or an array of proxy headers to trust when constructing the request URL.
3741
*
3842
* @remarks
3943
* When `trustProxyHeaders` is enabled, headers such as `X-Forwarded-Host` and
4044
* `X-Forwarded-Prefix` should ideally be strictly validated at a higher infrastructure
4145
* level (e.g., at the reverse proxy or API gateway) before reaching the application.
4246
*
4347
* @returns A Web Standard `Request` object.
44-
*
45-
* @private
4648
*/
4749
export function createWebRequestFromNodeRequest(
4850
nodeRequest: IncomingMessage | Http2ServerRequest,
4951
trustProxyHeaders?: boolean | readonly string[],
5052
): Request {
51-
const trustProxyHeadersNormalized =
52-
trustProxyHeaders && typeof trustProxyHeaders !== 'boolean'
53-
? new Set(trustProxyHeaders.map((h) => h.toLowerCase()))
54-
: trustProxyHeaders;
55-
53+
const trustProxyHeadersNormalized = normalizeTrustProxyHeaders(trustProxyHeaders);
5654
const { headers, method = 'GET' } = nodeRequest;
5755
const withBody = method !== 'GET' && method !== 'HEAD';
5856
const referrer = headers.referer && URL.canParse(headers.referer) ? headers.referer : undefined;
5957

6058
return new Request(createRequestUrl(nodeRequest, trustProxyHeadersNormalized), {
6159
method,
62-
headers: createRequestHeaders(headers, trustProxyHeadersNormalized),
60+
headers: createRequestHeaders(headers),
6361
body: withBody ? nodeRequest : undefined,
6462
duplex: withBody ? 'half' : undefined,
6563
referrer,
@@ -70,27 +68,16 @@ export function createWebRequestFromNodeRequest(
7068
* Creates a `Headers` object from Node.js `IncomingHttpHeaders`.
7169
*
7270
* @param nodeHeaders - The Node.js `IncomingHttpHeaders` object to convert.
73-
* @param trustProxyHeaders - A boolean or a set of allowed proxy headers.
7471
* @returns A `Headers` object containing the converted headers.
7572
*/
76-
function createRequestHeaders(
77-
nodeHeaders: IncomingHttpHeaders,
78-
trustProxyHeaders: boolean | ReadonlySet<string> | undefined,
79-
): Headers {
73+
function createRequestHeaders(nodeHeaders: IncomingHttpHeaders): Headers {
8074
const headers = new Headers();
8175

8276
for (const [name, value] of Object.entries(nodeHeaders)) {
8377
if (HTTP2_PSEUDO_HEADERS.has(name)) {
8478
continue;
8579
}
8680

87-
if (
88-
name.toLowerCase().startsWith('x-forwarded-') &&
89-
!isProxyHeaderAllowed(name, trustProxyHeaders)
90-
) {
91-
continue;
92-
}
93-
9481
if (typeof value === 'string') {
9582
headers.append(name, value);
9683
} else if (Array.isArray(value)) {
@@ -107,7 +94,7 @@ function createRequestHeaders(
10794
* Creates a `URL` object from a Node.js `IncomingMessage`, taking into account the protocol, host, and port.
10895
*
10996
* @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.
97+
* @param trustProxyHeaders - A set of allowed proxy headers.
11198
*
11299
* @remarks
113100
* When `trustProxyHeaders` is enabled, headers such as `X-Forwarded-Host` and
@@ -118,7 +105,7 @@ function createRequestHeaders(
118105
*/
119106
export function createRequestUrl(
120107
nodeRequest: IncomingMessage | Http2ServerRequest,
121-
trustProxyHeaders?: boolean | ReadonlySet<string>,
108+
trustProxyHeaders: ReadonlySet<string>,
122109
): URL {
123110
const {
124111
headers,
@@ -156,37 +143,15 @@ export function createRequestUrl(
156143
*
157144
* @param headers - The Node.js incoming HTTP headers.
158145
* @param headerName - The name of the proxy header to retrieve.
159-
* @param trustProxyHeaders - A boolean or a set of allowed proxy headers.
146+
* @param trustProxyHeaders - A set of allowed proxy headers.
160147
* @returns The value of the allowed proxy header, or `undefined` if not allowed or not present.
161148
*/
162149
function getAllowedProxyHeaderValue(
163150
headers: IncomingHttpHeaders,
164151
headerName: string,
165-
trustProxyHeaders: boolean | ReadonlySet<string> | undefined,
152+
trustProxyHeaders: ReadonlySet<string>,
166153
): string | undefined {
167154
return isProxyHeaderAllowed(headerName, trustProxyHeaders)
168155
? getFirstHeaderValue(headers[headerName])
169156
: undefined;
170157
}
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: 66 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,36 @@ 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(
115+
`Received "${key}" header but "trustProxyHeaders" was not set up to allow it.\n` +
116+
`For more information, see https://angular.dev/best-practices/security#configuring-trusted-proxy-headers`,
117+
);
118+
headersDeleted = true;
119+
} else {
120+
headers.set(key, value);
107121
}
108122
}
109123

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;
124+
return headersDeleted
125+
? new Request(request.clone(), {
126+
signal: request.signal,
127+
headers,
128+
})
129+
: request;
124130
}
125131

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

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)