Skip to content

Commit 5192751

Browse files
committed
fix(@angular/ssr): introduce trustProxyHeaders option to safely validate and sanitize proxy headers
This commit adds the `trustProxyHeaders` option to `AngularAppEngineOptions` and `AngularNodeAppEngineOptions` to configure, validate, and sanitize `X-Forwarded-*` headers. - When `trustProxyHeaders` is `undefined` (default): - Allows `X-Forwarded-Host` and `X-Forwarded-Proto`. - Intercepts `X-Forwarded-Prefix` and triggers a dynamic CSR deoptimization to skip SSR if present. - Logs an informative message when receiving any other `X-Forwarded-*` headers. - When `false`: - Ignores and strips all proxy headers from the request. - When `true`: - Trusts all proxy headers. - When a string array: - Allows only the proxy headers provided inside the array.
1 parent 2d53fec commit 5192751

11 files changed

Lines changed: 359 additions & 270 deletions

File tree

goldens/public-api/angular/ssr/index.api.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,14 @@ export class AngularAppEngine {
1414
constructor(options?: AngularAppEngineOptions);
1515
handle(request: Request, requestContext?: unknown): Promise<Response | null>;
1616
static ɵallowStaticRouteRender: boolean;
17+
static ɵdisableAllowedHostsCheck: boolean;
1718
static ɵhooks: Hooks;
1819
}
1920

2021
// @public
2122
export interface AngularAppEngineOptions {
2223
allowedHosts?: readonly string[];
24+
trustProxyHeaders?: boolean | readonly string[];
2325
}
2426

2527
// @public

goldens/public-api/angular/ssr/node/index.api.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ export interface CommonEngineRenderOptions {
5555
export function createNodeRequestHandler<T extends NodeRequestHandlerFunction>(handler: T): T;
5656

5757
// @public
58-
export function createWebRequestFromNodeRequest(nodeRequest: IncomingMessage | Http2ServerRequest): Request;
58+
export function createWebRequestFromNodeRequest(nodeRequest: IncomingMessage | Http2ServerRequest, trustProxyHeaders?: boolean | readonly string[]): Request;
5959

6060
// @public
6161
export function isMainModule(url: string): boolean;

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

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ export interface AngularNodeAppEngineOptions extends AngularAppEngineOptions {}
3030
*/
3131
export class AngularNodeAppEngine {
3232
private readonly angularAppEngine: AngularAppEngine;
33+
private readonly trustProxyHeaders?: boolean | readonly string[];
3334

3435
/**
3536
* Creates a new instance of the Angular Node.js server application engine.
@@ -40,6 +41,7 @@ export class AngularNodeAppEngine {
4041
...options,
4142
allowedHosts: [...getAllowedHostsFromEnv(), ...(options?.allowedHosts ?? [])],
4243
});
44+
this.trustProxyHeaders = options?.trustProxyHeaders;
4345
}
4446

4547
/**
@@ -75,7 +77,9 @@ export class AngularNodeAppEngine {
7577
requestContext?: unknown,
7678
): Promise<Response | null> {
7779
const webRequest =
78-
request instanceof Request ? request : createWebRequestFromNodeRequest(request);
80+
request instanceof Request
81+
? request
82+
: createWebRequestFromNodeRequest(request, this.trustProxyHeaders);
7983

8084
return this.angularAppEngine.handle(webRequest, requestContext);
8185
}

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

Lines changed: 58 additions & 7 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.
@@ -17,7 +21,13 @@ import { getFirstHeaderValue } from '../../src/utils/validation';
1721
* as they are not allowed to be set directly using the `Node.js` Undici API or
1822
* the web `Headers` API.
1923
*/
20-
const HTTP2_PSEUDO_HEADERS = new Set([':method', ':scheme', ':authority', ':path', ':status']);
24+
const HTTP2_PSEUDO_HEADERS: ReadonlySet<string> = new Set([
25+
':method',
26+
':scheme',
27+
':authority',
28+
':path',
29+
':status',
30+
]);
2131

2232
/**
2333
* Converts a Node.js `IncomingMessage` or `Http2ServerRequest` into a
@@ -27,16 +37,25 @@ const HTTP2_PSEUDO_HEADERS = new Set([':method', ':scheme', ':authority', ':path
2737
* be used by web platform APIs.
2838
*
2939
* @param nodeRequest - The Node.js request object (`IncomingMessage` or `Http2ServerRequest`) to convert.
40+
* @param trustProxyHeaders - A boolean or an array of proxy headers to trust when constructing the request URL.
41+
*
42+
* @remarks
43+
* When `trustProxyHeaders` is enabled, headers such as `X-Forwarded-Host` and
44+
* `X-Forwarded-Prefix` should ideally be strictly validated at a higher infrastructure
45+
* level (e.g., at the reverse proxy or API gateway) before reaching the application.
46+
*
3047
* @returns A Web Standard `Request` object.
3148
* @developerPreview
3249
*/
3350
export function createWebRequestFromNodeRequest(
3451
nodeRequest: IncomingMessage | Http2ServerRequest,
52+
trustProxyHeaders?: boolean | readonly string[],
3553
): Request {
54+
const trustProxyHeadersNormalized = normalizeTrustProxyHeaders(trustProxyHeaders);
3655
const { headers, method = 'GET' } = nodeRequest;
3756
const withBody = method !== 'GET' && method !== 'HEAD';
3857

39-
return new Request(createRequestUrl(nodeRequest), {
58+
return new Request(createRequestUrl(nodeRequest, trustProxyHeadersNormalized), {
4059
method,
4160
headers: createRequestHeaders(headers),
4261
body: withBody ? nodeRequest : undefined,
@@ -74,32 +93,64 @@ function createRequestHeaders(nodeHeaders: IncomingHttpHeaders): Headers {
7493
* Creates a `URL` object from a Node.js `IncomingMessage`, taking into account the protocol, host, and port.
7594
*
7695
* @param nodeRequest - The Node.js `IncomingMessage` or `Http2ServerRequest` object to extract URL information from.
96+
* @param trustProxyHeaders - A set of allowed proxy headers.
97+
*
98+
* @remarks
99+
* When `trustProxyHeaders` is enabled, headers such as `X-Forwarded-Host` and
100+
* `X-Forwarded-Prefix` should ideally be strictly validated at a higher infrastructure
101+
* level (e.g., at the reverse proxy or API gateway) before reaching the application.
102+
*
77103
* @returns A `URL` object representing the request URL.
78104
*/
79-
export function createRequestUrl(nodeRequest: IncomingMessage | Http2ServerRequest): URL {
105+
export function createRequestUrl(
106+
nodeRequest: IncomingMessage | Http2ServerRequest,
107+
trustProxyHeaders: ReadonlySet<string>,
108+
): URL {
80109
const {
81110
headers,
82111
socket,
83112
url = '',
84113
originalUrl,
85114
} = nodeRequest as IncomingMessage & { originalUrl?: string };
115+
86116
const protocol =
87-
getFirstHeaderValue(headers['x-forwarded-proto']) ??
117+
getAllowedProxyHeaderValue(headers, 'x-forwarded-proto', trustProxyHeaders) ??
88118
('encrypted' in socket && socket.encrypted ? 'https' : 'http');
119+
89120
const hostname =
90-
getFirstHeaderValue(headers['x-forwarded-host']) ?? headers.host ?? headers[':authority'];
121+
getAllowedProxyHeaderValue(headers, 'x-forwarded-host', trustProxyHeaders) ??
122+
headers.host ??
123+
headers[':authority'];
91124

92125
if (Array.isArray(hostname)) {
93126
throw new Error('host value cannot be an array.');
94127
}
95128

96129
let hostnameWithPort = hostname;
97130
if (!hostname?.includes(':')) {
98-
const port = getFirstHeaderValue(headers['x-forwarded-port']);
131+
const port = getAllowedProxyHeaderValue(headers, 'x-forwarded-port', trustProxyHeaders);
99132
if (port) {
100133
hostnameWithPort += `:${port}`;
101134
}
102135
}
103136

104137
return new URL(`${protocol}://${hostnameWithPort}${originalUrl ?? url}`);
105138
}
139+
140+
/**
141+
* Gets the first value of an allowed proxy header.
142+
*
143+
* @param headers - The Node.js incoming HTTP headers.
144+
* @param headerName - The name of the proxy header to retrieve.
145+
* @param trustProxyHeaders - A set of allowed proxy headers.
146+
* @returns The value of the allowed proxy header, or `undefined` if not allowed or not present.
147+
*/
148+
function getAllowedProxyHeaderValue(
149+
headers: IncomingHttpHeaders,
150+
headerName: string,
151+
trustProxyHeaders: ReadonlySet<string>,
152+
): string | undefined {
153+
return isProxyHeaderAllowed(headerName, trustProxyHeaders)
154+
? getFirstHeaderValue(headers[headerName])
155+
: undefined;
156+
}

packages/angular/ssr/node/test/BUILD.bazel

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ ts_project(
77
srcs = glob(["**/*_spec.ts"]),
88
deps = [
99
"//:node_modules/@types/node",
10+
"//packages/angular/ssr",
1011
"//packages/angular/ssr/node",
1112
],
1213
)

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

Lines changed: 11 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,8 @@
77
*/
88

99
import { IncomingMessage } from 'node:http';
10-
import { Http2ServerRequest } from 'node:http2';
1110
import { Socket } from 'node:net';
11+
import { normalizeTrustProxyHeaders } from '../../src/utils/validation';
1212
import { createRequestUrl } from '../src/request';
1313

1414
// Helper to create a mock request object for testing.
@@ -26,25 +26,14 @@ function createRequest(details: {
2626
} as unknown as IncomingMessage;
2727
}
2828

29-
// Helper to create a mock Http2ServerRequest object for testing.
30-
function createHttp2Request(details: {
31-
headers: Record<string, string | string[] | undefined>;
32-
url?: string;
33-
}): Http2ServerRequest {
34-
return {
35-
headers: details.headers,
36-
socket: new Socket(),
37-
url: details.url,
38-
} as Http2ServerRequest;
39-
}
40-
4129
describe('createRequestUrl', () => {
4230
it('should create a http URL with hostname and port from the host header', () => {
4331
const url = createRequestUrl(
4432
createRequest({
4533
headers: { host: 'localhost:8080' },
4634
url: '/test',
4735
}),
36+
new Set(),
4837
);
4938
expect(url.href).toBe('http://localhost:8080/test');
5039
});
@@ -56,6 +45,7 @@ describe('createRequestUrl', () => {
5645
encryptedSocket: true,
5746
url: '/test',
5847
}),
48+
new Set(),
5949
);
6050
expect(url.href).toBe('https://example.com/test');
6151
});
@@ -67,6 +57,7 @@ describe('createRequestUrl', () => {
6757
encryptedSocket: true,
6858
url: '',
6959
}),
60+
new Set(),
7061
);
7162
expect(url.href).toBe('https://example.com/');
7263
});
@@ -78,6 +69,7 @@ describe('createRequestUrl', () => {
7869
encryptedSocket: true,
7970
url: '/test?a=1',
8071
}),
72+
new Set(),
8173
);
8274
expect(url.href).toBe('https://example.com/test?a=1');
8375
});
@@ -90,6 +82,7 @@ describe('createRequestUrl', () => {
9082
url: '/test',
9183
originalUrl: '/original',
9284
}),
85+
new Set(),
9386
);
9487
expect(url.href).toBe('https://example.com/original');
9588
});
@@ -102,6 +95,7 @@ describe('createRequestUrl', () => {
10295
url: undefined,
10396
originalUrl: undefined,
10497
}),
98+
new Set(),
10599
);
106100
expect(url.href).toBe('https://example.com/');
107101
});
@@ -112,6 +106,7 @@ describe('createRequestUrl', () => {
112106
headers: { host: 'localhost:8080' },
113107
url: '//example.com/test',
114108
}),
109+
new Set(),
115110
);
116111
expect(url.href).toBe('http://localhost:8080//example.com/test');
117112
});
@@ -123,6 +118,7 @@ describe('createRequestUrl', () => {
123118
url: '/test',
124119
originalUrl: '//example.com/original',
125120
}),
121+
new Set(),
126122
);
127123
expect(url.href).toBe('http://localhost:8080//example.com/original');
128124
});
@@ -137,6 +133,7 @@ describe('createRequestUrl', () => {
137133
},
138134
url: '/test',
139135
}),
136+
normalizeTrustProxyHeaders(true),
140137
);
141138
expect(url.href).toBe('https://example.com/test');
142139
});
@@ -152,6 +149,7 @@ describe('createRequestUrl', () => {
152149
},
153150
url: '/test',
154151
}),
152+
normalizeTrustProxyHeaders(true),
155153
);
156154
expect(url.href).toBe('https://example.com:8443/test');
157155
});

0 commit comments

Comments
 (0)