Skip to content

Commit fdb4c43

Browse files
committed
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. Previously, only non-standard `x-forwarded-*` headers were supported for resolving proxy-forwarded protocols, hosts, and ports. With this change: - A new `parseForwardedHeader` utility is added to parse standard `Forwarded` header parameters (such as `host` and `proto`), correctly handling quoted values and escaped characters. - In `createRequestUrl`, if the `Forwarded` header is trusted (via `trustProxyHeaders` configuration), its `host` and `proto` parameters are extracted and take precedence over corresponding `x-forwarded-host` and `x-forwarded-proto` headers. - Request validation is updated to verify the validity of `Forwarded` host and proto parameters. - Request sanitization is updated to scrub or retain the `Forwarded` header based on the configured trusted proxy headers. Reference: https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Forwarded
1 parent 151d426 commit fdb4c43

5 files changed

Lines changed: 199 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: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,4 +153,48 @@ 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 ignore "forwarded" header when it is not trusted', () => {
188+
const url = createRequestUrl(
189+
createRequest({
190+
headers: {
191+
host: 'localhost:8080',
192+
'forwarded': 'host=example.com;proto=https',
193+
},
194+
url: '/test',
195+
}),
196+
normalizeTrustProxyHeaders(false),
197+
);
198+
expect(url.href).toBe('http://localhost:8080/test');
199+
});
156200
});

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: 63 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,57 @@ 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 firstElement = headerValue.split(',', 1)[0];
301+
const params: Record<string, string> = {};
302+
303+
for (const param of firstElement.split(';')) {
304+
const eqIndex = param.indexOf('=');
305+
if (eqIndex === -1) {
306+
continue;
307+
}
308+
309+
const key = param.slice(0, eqIndex).trim().toLowerCase();
310+
let val = param.slice(eqIndex + 1).trim();
311+
if (val[0] === '"' && val.at(-1) === '"') {
312+
val = val.slice(1, -1).replace(/\\(.)/g, '$1');
313+
}
314+
315+
params[key] = val;
316+
}
317+
318+
return params;
319+
}

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

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,10 @@ describe('Validation Utils', () => {
5757
);
5858
});
5959

60+
it('should return a set containing "forwarded" when input is an array containing it', () => {
61+
expect(normalizeTrustProxyHeaders(['Forwarded'])).toEqual(new Set(['forwarded']));
62+
});
63+
6064
it('should throw an error if input array contains "*"', () => {
6165
expect(() => normalizeTrustProxyHeaders(['*'])).toThrowError(
6266
'"*" is not allowed as a value for the "trustProxyHeaders" option.',
@@ -65,6 +69,15 @@ describe('Validation Utils', () => {
6569
'"*" is not allowed as a value for the "trustProxyHeaders" option.',
6670
);
6771
});
72+
73+
it('should throw an error if input array contains an invalid proxy header name', () => {
74+
expect(() => normalizeTrustProxyHeaders(['invalid-header'])).toThrowError(
75+
'"invalid-header" is not a valid proxy header. Trusted proxy headers must be "forwarded" or start with "x-forwarded-".',
76+
);
77+
expect(() => normalizeTrustProxyHeaders(['x-forward-host'])).toThrowError(
78+
'"x-forward-host" is not a valid proxy header. Trusted proxy headers must be "forwarded" or start with "x-forwarded-".',
79+
);
80+
});
6881
});
6982

7083
describe('validateUrl', () => {
@@ -184,6 +197,49 @@ describe('Validation Utils', () => {
184197
);
185198
});
186199

200+
it('should pass for valid request with forwarded header', () => {
201+
const req = new Request('http://example.com', {
202+
headers: {
203+
'forwarded': 'host=example.com;proto=https',
204+
},
205+
});
206+
207+
expect(() => validateRequest(req, allowedHosts, false)).not.toThrow();
208+
});
209+
210+
it('should throw if forwarded host contains path separators', () => {
211+
const req = new Request('http://example.com', {
212+
headers: {
213+
'forwarded': 'host="example.com/bad"',
214+
},
215+
});
216+
expect(() => validateRequest(req, allowedHosts, false)).toThrowError(
217+
'Header "Forwarded "host"" with value "example.com/bad" contains characters that are not allowed.',
218+
);
219+
});
220+
221+
it('should throw if forwarded host is not allowed', () => {
222+
const req = new Request('http://example.com', {
223+
headers: {
224+
'forwarded': 'host=evil.com',
225+
},
226+
});
227+
expect(() => validateRequest(req, allowedHosts, false)).toThrowError(
228+
'Header "Forwarded "host"" with value "evil.com" is not allowed.',
229+
);
230+
});
231+
232+
it('should throw if forwarded proto is invalid', () => {
233+
const req = new Request('http://example.com', {
234+
headers: {
235+
'forwarded': 'proto=ftp',
236+
},
237+
});
238+
expect(() => validateRequest(req, allowedHosts, false)).toThrowError(
239+
'Header "forwarded" proto parameter must be either "http" or "https".',
240+
);
241+
});
242+
187243
it('should throw error if x-forwarded-prefix is invalid', () => {
188244
const inputs = [
189245
'//evil',
@@ -317,5 +373,31 @@ describe('Validation Utils', () => {
317373
expect(secured).toBe(req);
318374
expect(secured.headers.get('accept')).toBe('application/json');
319375
});
376+
377+
it('should scrub unallowed forwarded header by default', () => {
378+
const req = new Request('http://example.com', {
379+
headers: {
380+
'host': 'example.com',
381+
'forwarded': 'host=evil.com;proto=https',
382+
},
383+
});
384+
const secured = sanitizeRequestHeaders(req, normalizeTrustProxyHeaders(undefined));
385+
386+
expect(secured.headers.get('host')).toBe('example.com');
387+
expect(secured.headers.has('forwarded')).toBeFalse();
388+
});
389+
390+
it('should retain allowed forwarded header when explicitly provided', () => {
391+
const req = new Request('http://example.com', {
392+
headers: {
393+
'host': 'example.com',
394+
'forwarded': 'host=proxy.com;proto=https',
395+
},
396+
});
397+
const secured = sanitizeRequestHeaders(req, normalizeTrustProxyHeaders(['forwarded']));
398+
399+
expect(secured.headers.get('host')).toBe('example.com');
400+
expect(secured.headers.get('forwarded')).toBe('host=proxy.com;proto=https');
401+
});
320402
});
321403
});

0 commit comments

Comments
 (0)