Skip to content

Commit 3c808cd

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

6 files changed

Lines changed: 110 additions & 104 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/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 & 15 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,7 +133,7 @@ describe('createRequestUrl', () => {
137133
},
138134
url: '/test',
139135
}),
140-
true,
136+
normalizeTrustProxyHeaders(true),
141137
);
142138
expect(url.href).toBe('https://example.com/test');
143139
});
@@ -153,7 +149,7 @@ describe('createRequestUrl', () => {
153149
},
154150
url: '/test',
155151
}),
156-
true,
152+
normalizeTrustProxyHeaders(true),
157153
);
158154
expect(url.href).toBe('https://example.com:8443/test');
159155
});

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> {

0 commit comments

Comments
 (0)