Skip to content

Commit 7ef9ed2

Browse files
authored
feat(@angular/ssr): support the standard Forwarded header
This commit adds support for the standard RFC 7239 `Forwarded` header in the Angular SSR request parsing and validation layers.
1 parent 316cc20 commit 7ef9ed2

5 files changed

Lines changed: 408 additions & 10 deletions

File tree

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

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
getFirstHeaderValue,
1313
isProxyHeaderAllowed,
1414
normalizeTrustProxyHeaders,
15+
parseForwardedHeader,
1516
} from '../../src/utils/validation';
1617

1718
/**
@@ -40,7 +41,7 @@ const HTTP2_PSEUDO_HEADERS: ReadonlySet<string> = new Set([
4041
* @param trustProxyHeaders - A boolean or an array of proxy headers to trust when constructing the request URL.
4142
*
4243
* @remarks
43-
* When `trustProxyHeaders` is enabled, headers such as `X-Forwarded-Host` and
44+
* When `trustProxyHeaders` is enabled, headers such as `Forwarded`, `X-Forwarded-Host`, and
4445
* `X-Forwarded-Prefix` should ideally be strictly validated at a higher infrastructure
4546
* level (e.g., at the reverse proxy or API gateway) before reaching the application.
4647
*
@@ -97,7 +98,7 @@ function createRequestHeaders(nodeHeaders: IncomingHttpHeaders): Headers {
9798
* @param trustProxyHeaders - A set of allowed proxy headers.
9899
*
99100
* @remarks
100-
* When `trustProxyHeaders` is enabled, headers such as `X-Forwarded-Host` and
101+
* When `trustProxyHeaders` is enabled, headers such as `Forwarded`, `X-Forwarded-Host`, and
101102
* `X-Forwarded-Prefix` should ideally be strictly validated at a higher infrastructure
102103
* level (e.g., at the reverse proxy or API gateway) before reaching the application.
103104
*
@@ -114,11 +115,16 @@ export function createRequestUrl(
114115
originalUrl,
115116
} = nodeRequest as IncomingMessage & { originalUrl?: string };
116117

118+
const forwardedHeaderValue = getAllowedProxyHeaderValue(headers, 'forwarded', trustProxyHeaders);
119+
const forwardedParams = parseForwardedHeader(forwardedHeaderValue);
120+
117121
const protocol =
122+
forwardedParams.proto ??
118123
getAllowedProxyHeaderValue(headers, 'x-forwarded-proto', trustProxyHeaders) ??
119124
('encrypted' in socket && socket.encrypted ? 'https' : 'http');
120125

121126
const hostname =
127+
forwardedParams.host ??
122128
getAllowedProxyHeaderValue(headers, 'x-forwarded-host', trustProxyHeaders) ??
123129
headers.host ??
124130
headers[':authority'];

packages/angular/ssr/node/test/request_spec.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,4 +153,62 @@ describe('createRequestUrl', () => {
153153
);
154154
expect(url.href).toBe('https://example.com:8443/test');
155155
});
156+
157+
it('should prioritize "forwarded" header over standard and x-forwarded headers', () => {
158+
const url = createRequestUrl(
159+
createRequest({
160+
headers: {
161+
host: 'localhost:8080',
162+
'x-forwarded-host': 'other.com',
163+
'x-forwarded-proto': 'http',
164+
'forwarded': 'host=example.com;proto=https',
165+
},
166+
url: '/test',
167+
}),
168+
normalizeTrustProxyHeaders(true),
169+
);
170+
expect(url.href).toBe('https://example.com/test');
171+
});
172+
173+
it('should parse forwarded parameters correctly (including quoted values)', () => {
174+
const url = createRequestUrl(
175+
createRequest({
176+
headers: {
177+
host: 'localhost:8080',
178+
'forwarded': 'host="example.com:8443";proto="https"',
179+
},
180+
url: '/test',
181+
}),
182+
normalizeTrustProxyHeaders(true),
183+
);
184+
expect(url.href).toBe('https://example.com:8443/test');
185+
});
186+
187+
it('should not treat parameters inside quoted values as top-level parameters in "forwarded" header', () => {
188+
const url = createRequestUrl(
189+
createRequest({
190+
headers: {
191+
host: 'localhost:8080',
192+
'forwarded': 'for="192.0.2.60;host=evil.com";proto=https',
193+
},
194+
url: '/test',
195+
}),
196+
normalizeTrustProxyHeaders(true),
197+
);
198+
expect(url.href).toBe('https://localhost:8080/test');
199+
});
200+
201+
it('should ignore "forwarded" header when it is not trusted', () => {
202+
const url = createRequestUrl(
203+
createRequest({
204+
headers: {
205+
host: 'localhost:8080',
206+
'forwarded': 'host=example.com;proto=https',
207+
},
208+
url: '/test',
209+
}),
210+
normalizeTrustProxyHeaders(false),
211+
);
212+
expect(url.href).toBe('http://localhost:8080/test');
213+
});
156214
});

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,12 +28,12 @@ export interface AngularAppEngineOptions {
2828
allowedHosts?: readonly string[];
2929

3030
/**
31-
* Extends the scope of trusted proxy headers (`X-Forwarded-*`).
31+
* Extends the scope of trusted proxy headers (`Forwarded` or `X-Forwarded-*`).
3232
*
3333
* @remarks
3434
* **This is a security-sensitive option!**
3535
*
36-
* When `trustProxyHeaders` is enabled, request headers such as `X-Forwarded-Host` and
36+
* When `trustProxyHeaders` is enabled, request headers such as `Forwarded`, `X-Forwarded-Host`, and
3737
* `X-Forwarded-Prefix` are trusted by the server and used for routing. These
3838
* headers must be strictly validated and provided by a trusted client (e.g., at a reverse proxy, load
3939
* balancer, or API gateway) and must *not* be provided by untrusted end users.

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

Lines changed: 164 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,8 @@ export function sanitizeRequestHeaders(
103103

104104
for (const [key, value] of request.headers) {
105105
const lowerKey = key.toLowerCase();
106-
if (lowerKey.startsWith('x-forwarded-') && !isProxyHeaderAllowed(lowerKey, trustProxyHeaders)) {
106+
const isProxyHeader = lowerKey === 'forwarded' || lowerKey.startsWith('x-forwarded-');
107+
if (isProxyHeader && !isProxyHeaderAllowed(lowerKey, trustProxyHeaders)) {
107108
// eslint-disable-next-line no-console
108109
console.warn(
109110
`Received "${key}" header but "trustProxyHeaders" was not set up to allow it.\n` +
@@ -199,6 +200,17 @@ function validateHeaders(
199200
}
200201
}
201202

203+
const forwarded = headers.get('forwarded');
204+
if (forwarded) {
205+
const forwardedParams = parseForwardedHeader(forwarded);
206+
if (forwardedParams.host && !disableHostCheck) {
207+
verifyHostAllowed('Forwarded "host"', forwardedParams.host, allowedHosts);
208+
}
209+
if (forwardedParams.proto && !VALID_PROTO_REGEX.test(forwardedParams.proto)) {
210+
throw new Error('Header "forwarded" proto parameter must be either "http" or "https".');
211+
}
212+
}
213+
202214
const xForwardedPort = getFirstHeaderValue(headers.get('x-forwarded-port'));
203215
if (xForwardedPort && !VALID_PORT_REGEX.test(xForwardedPort)) {
204216
throw new Error('Header "x-forwarded-port" must be a numeric value.');
@@ -251,12 +263,158 @@ export function normalizeTrustProxyHeaders(
251263
return new Set([TRUST_ALL_PROXY_HEADERS]);
252264
}
253265

254-
const normalizedTrustedProxyHeaders = new Set(trustProxyHeaders.map((h) => h.toLowerCase()));
255-
if (normalizedTrustedProxyHeaders.has(TRUST_ALL_PROXY_HEADERS)) {
256-
throw new Error(
257-
`"${TRUST_ALL_PROXY_HEADERS}" is not allowed as a value for the "trustProxyHeaders" option.`,
258-
);
266+
const normalizedTrustedProxyHeaders = new Set<string>();
267+
for (const header of trustProxyHeaders) {
268+
const lowerHeader = header.toLowerCase();
269+
if (lowerHeader === TRUST_ALL_PROXY_HEADERS) {
270+
throw new Error(
271+
`"${TRUST_ALL_PROXY_HEADERS}" is not allowed as a value for the "trustProxyHeaders" option.`,
272+
);
273+
}
274+
const isValid = lowerHeader === 'forwarded' || lowerHeader.startsWith('x-forwarded-');
275+
if (!isValid) {
276+
throw new Error(
277+
`"${header}" is not a valid proxy header. Trusted proxy headers must be "forwarded" or start with "x-forwarded-".`,
278+
);
279+
}
280+
normalizedTrustedProxyHeaders.add(lowerHeader);
259281
}
260282

261283
return normalizedTrustedProxyHeaders;
262284
}
285+
286+
/**
287+
* Parses the standard `Forwarded` header (RFC 7239).
288+
* It extracts the parameters from the first (leftmost) element in the header.
289+
*
290+
* @param headerValue - The value of the `Forwarded` header.
291+
* @returns A record of lowercase parameter names to their values.
292+
*/
293+
export function parseForwardedHeader(
294+
headerValue: string | null | undefined,
295+
): Record<string, string> {
296+
if (!headerValue) {
297+
return {};
298+
}
299+
300+
const params: Record<string, string> = {};
301+
let inQuotes = false;
302+
let escaped = false;
303+
let currentKey = '';
304+
let currentValue = '';
305+
let isParsingValue = false;
306+
let isKeyEnded = false;
307+
let isParsingValueEnded = false;
308+
309+
for (const char of headerValue) {
310+
if (escaped) {
311+
escaped = false;
312+
if (isParsingValue) {
313+
currentValue += char;
314+
} else {
315+
currentKey += char;
316+
}
317+
continue;
318+
}
319+
320+
if (char === '\\') {
321+
if (inQuotes) {
322+
escaped = true;
323+
} else if (isParsingValue) {
324+
currentValue += char;
325+
} else {
326+
currentKey += char;
327+
}
328+
continue;
329+
}
330+
331+
if (char === '"') {
332+
inQuotes = !inQuotes;
333+
continue;
334+
}
335+
336+
if (inQuotes) {
337+
if (isParsingValue) {
338+
currentValue += char;
339+
} else {
340+
currentKey += char;
341+
}
342+
continue;
343+
}
344+
345+
if (char === ',') {
346+
addParam(currentKey, currentValue, isParsingValue, params);
347+
break;
348+
}
349+
350+
if (char === ';') {
351+
addParam(currentKey, currentValue, isParsingValue, params);
352+
currentKey = '';
353+
currentValue = '';
354+
isParsingValue = false;
355+
isKeyEnded = false;
356+
isParsingValueEnded = false;
357+
continue;
358+
}
359+
360+
if (char === '=') {
361+
if (!isParsingValue) {
362+
isParsingValue = true;
363+
} else {
364+
currentValue += char;
365+
}
366+
continue;
367+
}
368+
369+
if (char === ' ' || char === '\t') {
370+
if (isParsingValue) {
371+
if (currentValue.length > 0) {
372+
isParsingValueEnded = true;
373+
}
374+
} else if (currentKey.length > 0) {
375+
isKeyEnded = true;
376+
}
377+
continue;
378+
}
379+
380+
if (isParsingValue) {
381+
if (!isParsingValueEnded) {
382+
currentValue += char;
383+
}
384+
} else if (isKeyEnded) {
385+
currentKey = char;
386+
isKeyEnded = false;
387+
} else {
388+
currentKey += char;
389+
}
390+
}
391+
392+
if (currentKey || currentValue || isParsingValue) {
393+
addParam(currentKey, currentValue, isParsingValue, params);
394+
}
395+
396+
return params;
397+
}
398+
399+
/**
400+
* Helper function to add a parameter to the params object.
401+
* @param key - The key to add.
402+
* @param value - The value to add.
403+
* @param hasValue - Whether the parameter has a value.
404+
* @param params - The params object to add the parameter to.
405+
*/
406+
function addParam(
407+
key: string,
408+
value: string,
409+
hasValue: boolean,
410+
params: Record<string, string>,
411+
): void {
412+
if (!hasValue) {
413+
return;
414+
}
415+
416+
const trimmedKey = key.trim().toLowerCase();
417+
if (trimmedKey) {
418+
params[trimmedKey] = value;
419+
}
420+
}

0 commit comments

Comments
 (0)