Skip to content

Commit 94023f6

Browse files
authored
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 5b1a5b7 commit 94023f6

11 files changed

Lines changed: 307 additions & 309 deletions

File tree

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ export class AngularAppEngine {
2222
// @public
2323
export interface AngularAppEngineOptions {
2424
allowedHosts?: readonly string[];
25+
trustProxyHeaders?: boolean | readonly string[];
2526
}
2627

2728
// @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
@@ -29,6 +29,7 @@ export interface AngularNodeAppEngineOptions extends AngularAppEngineOptions {}
2929
*/
3030
export class AngularNodeAppEngine {
3131
private readonly angularAppEngine: AngularAppEngine;
32+
private readonly trustProxyHeaders?: boolean | readonly string[];
3233

3334
/**
3435
* Creates a new instance of the Angular Node.js server application engine.
@@ -39,6 +40,7 @@ export class AngularNodeAppEngine {
3940
...options,
4041
allowedHosts: [...getAllowedHostsFromEnv(), ...(options?.allowedHosts ?? [])],
4142
});
43+
this.trustProxyHeaders = options?.trustProxyHeaders;
4244

4345
attachNodeGlobalErrorHandlers();
4446
}
@@ -76,7 +78,9 @@ export class AngularNodeAppEngine {
7678
requestContext?: unknown,
7779
): Promise<Response | null> {
7880
const webRequest =
79-
request instanceof Request ? request : createWebRequestFromNodeRequest(request);
81+
request instanceof Request
82+
? request
83+
: createWebRequestFromNodeRequest(request, this.trustProxyHeaders);
8084

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

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
*/
3249
export function createWebRequestFromNodeRequest(
3350
nodeRequest: IncomingMessage | Http2ServerRequest,
51+
trustProxyHeaders?: boolean | readonly string[],
3452
): Request {
53+
const trustProxyHeadersNormalized = normalizeTrustProxyHeaders(trustProxyHeaders);
3554
const { headers, method = 'GET' } = nodeRequest;
3655
const withBody = method !== 'GET' && method !== 'HEAD';
3756
const referrer = headers.referer && URL.canParse(headers.referer) ? headers.referer : undefined;
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,
@@ -75,32 +94,64 @@ function createRequestHeaders(nodeHeaders: IncomingHttpHeaders): Headers {
7594
* Creates a `URL` object from a Node.js `IncomingMessage`, taking into account the protocol, host, and port.
7695
*
7796
* @param nodeRequest - The Node.js `IncomingMessage` or `Http2ServerRequest` object to extract URL information from.
97+
* @param trustProxyHeaders - A set of allowed proxy headers.
98+
*
99+
* @remarks
100+
* When `trustProxyHeaders` is enabled, headers such as `X-Forwarded-Host` and
101+
* `X-Forwarded-Prefix` should ideally be strictly validated at a higher infrastructure
102+
* level (e.g., at the reverse proxy or API gateway) before reaching the application.
103+
*
78104
* @returns A `URL` object representing the request URL.
79105
*/
80-
export function createRequestUrl(nodeRequest: IncomingMessage | Http2ServerRequest): URL {
106+
export function createRequestUrl(
107+
nodeRequest: IncomingMessage | Http2ServerRequest,
108+
trustProxyHeaders: ReadonlySet<string>,
109+
): URL {
81110
const {
82111
headers,
83112
socket,
84113
url = '',
85114
originalUrl,
86115
} = nodeRequest as IncomingMessage & { originalUrl?: string };
116+
87117
const protocol =
88-
getFirstHeaderValue(headers['x-forwarded-proto']) ??
118+
getAllowedProxyHeaderValue(headers, 'x-forwarded-proto', trustProxyHeaders) ??
89119
('encrypted' in socket && socket.encrypted ? 'https' : 'http');
120+
90121
const hostname =
91-
getFirstHeaderValue(headers['x-forwarded-host']) ?? headers.host ?? headers[':authority'];
122+
getAllowedProxyHeaderValue(headers, 'x-forwarded-host', trustProxyHeaders) ??
123+
headers.host ??
124+
headers[':authority'];
92125

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

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

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

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
});

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

Lines changed: 36 additions & 19 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 { cloneRequestAndPatchHeaders, 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.
@@ -22,6 +26,22 @@ export interface AngularAppEngineOptions {
2226
* A set of allowed hostnames for the server application.
2327
*/
2428
allowedHosts?: readonly string[];
29+
30+
/**
31+
* Extends the scope of trusted proxy headers (`X-Forwarded-*`).
32+
*
33+
* @remarks
34+
* When `trustProxyHeaders` is enabled, headers such as `X-Forwarded-Host` and
35+
* `X-Forwarded-Prefix` should ideally be strictly validated at a higher infrastructure
36+
* level (e.g., at the reverse proxy or API gateway) before reaching the application.
37+
*
38+
* If a `string[]` is provided, only those proxy headers are allowed.
39+
* If `true`, all proxy headers are allowed.
40+
* If `false`, proxy headers are ignored.
41+
*
42+
* @default undefined
43+
*/
44+
trustProxyHeaders?: boolean | readonly string[];
2545
}
2646

2747
/**
@@ -78,6 +98,11 @@ export class AngularAppEngine {
7898
this.manifest.supportedLocales,
7999
);
80100

101+
/**
102+
* The normalized allowed proxy headers.
103+
*/
104+
private readonly trustProxyHeaders: ReadonlySet<string>;
105+
81106
/**
82107
* A cache that holds entry points, keyed by their potential locale string.
83108
*/
@@ -89,6 +114,7 @@ export class AngularAppEngine {
89114
*/
90115
constructor(options?: AngularAppEngineOptions) {
91116
this.allowedHosts = this.getAllowedHosts(options);
117+
this.trustProxyHeaders = normalizeTrustProxyHeaders(options?.trustProxyHeaders);
92118
}
93119

94120
private getAllowedHosts(options: AngularAppEngineOptions | undefined): ReadonlySet<string> {
@@ -131,33 +157,24 @@ export class AngularAppEngine {
131157
*/
132158
async handle(request: Request, requestContext?: unknown): Promise<Response | null> {
133159
const allowedHost = this.allowedHosts;
134-
const disableAllowedHostsCheck = AngularAppEngine.ɵdisableAllowedHostsCheck;
160+
const { request: securedRequest, deoptToCSR } = sanitizeRequestHeaders(
161+
request,
162+
this.trustProxyHeaders,
163+
);
135164

136165
try {
137-
validateRequest(request, allowedHost, disableAllowedHostsCheck);
166+
validateRequest(securedRequest, allowedHost, AngularAppEngine.ɵdisableAllowedHostsCheck);
138167
} catch (error) {
139-
return this.handleValidationError(error as Error, request);
168+
return this.handleValidationError(error as Error, securedRequest);
140169
}
141170

142-
// Clone request with patched headers to prevent unallowed host header access.
143-
const { request: securedRequest, onError: onHeaderValidationError } = disableAllowedHostsCheck
144-
? { request, onError: null }
145-
: cloneRequestAndPatchHeaders(request, allowedHost);
146-
147171
const serverApp = await this.getAngularServerAppForRequest(securedRequest);
148172
if (serverApp) {
149-
const promises: Promise<Response | null>[] = [];
150-
if (onHeaderValidationError) {
151-
promises.push(
152-
onHeaderValidationError.then((error) =>
153-
this.handleValidationError(error, securedRequest),
154-
),
155-
);
173+
if (deoptToCSR) {
174+
return serverApp.serveClientSidePage();
156175
}
157176

158-
promises.push(serverApp.handle(securedRequest, requestContext));
159-
160-
return Promise.race(promises);
177+
return serverApp.handle(securedRequest, requestContext);
161178
}
162179

163180
if (this.supportedLocales.length > 1) {

packages/angular/ssr/src/app.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -190,8 +190,7 @@ export class AngularServerApp {
190190
return null;
191191
}
192192

193-
const { redirectTo, status, renderMode, headers } = matchedRoute;
194-
193+
const { redirectTo, status, renderMode, headers, preload } = matchedRoute;
195194
if (redirectTo !== undefined) {
196195
return createRedirectResponse(
197196
joinUrlParts(

0 commit comments

Comments
 (0)