Skip to content

Commit 01575d6

Browse files
committed
fixup! fix(@angular/ssr): introduce trustProxyHeaders option to safely validate and sanitize proxy headers
1 parent e769eae commit 01575d6

5 files changed

Lines changed: 102 additions & 111 deletions

File tree

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

Lines changed: 11 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
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 { getFirstHeaderValue, normalizeTrustProxyHeaders } from '../../src/utils/validation';
1212

1313
/**
1414
* A set containing all the pseudo-headers defined in the HTTP/2 specification.
@@ -33,7 +33,7 @@ const HTTP2_PSEUDO_HEADERS: ReadonlySet<string> = new Set([
3333
* be used by web platform APIs.
3434
*
3535
* @param nodeRequest - The Node.js request object (`IncomingMessage` or `Http2ServerRequest`) to convert.
36-
* @param trustProxyHeaders - A boolean or an array of allowed proxy headers.
36+
* @param trustProxyHeaders - A boolean or an array of proxy headers to trust when constructing the request URL.
3737
*
3838
* @remarks
3939
* When `trustProxyHeaders` is enabled, headers such as `X-Forwarded-Host` and
@@ -46,18 +46,14 @@ export function createWebRequestFromNodeRequest(
4646
nodeRequest: IncomingMessage | Http2ServerRequest,
4747
trustProxyHeaders?: boolean | readonly string[],
4848
): Request {
49-
const trustProxyHeadersNormalized =
50-
trustProxyHeaders && typeof trustProxyHeaders !== 'boolean'
51-
? new Set(trustProxyHeaders.map((h) => h.toLowerCase()))
52-
: trustProxyHeaders;
53-
49+
const trustProxyHeadersNormalized = normalizeTrustProxyHeaders(trustProxyHeaders);
5450
const { headers, method = 'GET' } = nodeRequest;
5551
const withBody = method !== 'GET' && method !== 'HEAD';
5652
const referrer = headers.referer && URL.canParse(headers.referer) ? headers.referer : undefined;
5753

5854
return new Request(createRequestUrl(nodeRequest, trustProxyHeadersNormalized), {
5955
method,
60-
headers: createRequestHeaders(headers, trustProxyHeadersNormalized),
56+
headers: createRequestHeaders(headers),
6157
body: withBody ? nodeRequest : undefined,
6258
duplex: withBody ? 'half' : undefined,
6359
referrer,
@@ -68,27 +64,16 @@ export function createWebRequestFromNodeRequest(
6864
* Creates a `Headers` object from Node.js `IncomingHttpHeaders`.
6965
*
7066
* @param nodeHeaders - The Node.js `IncomingHttpHeaders` object to convert.
71-
* @param trustProxyHeaders - A boolean or a set of allowed proxy headers.
7267
* @returns A `Headers` object containing the converted headers.
7368
*/
74-
function createRequestHeaders(
75-
nodeHeaders: IncomingHttpHeaders,
76-
trustProxyHeaders: boolean | ReadonlySet<string> | undefined,
77-
): Headers {
69+
function createRequestHeaders(nodeHeaders: IncomingHttpHeaders): Headers {
7870
const headers = new Headers();
7971

8072
for (const [name, value] of Object.entries(nodeHeaders)) {
8173
if (HTTP2_PSEUDO_HEADERS.has(name)) {
8274
continue;
8375
}
8476

85-
if (
86-
name.toLowerCase().startsWith('x-forwarded-') &&
87-
!isProxyHeaderAllowed(name.toLowerCase(), trustProxyHeaders)
88-
) {
89-
continue;
90-
}
91-
9277
if (typeof value === 'string') {
9378
headers.append(name, value);
9479
} else if (Array.isArray(value)) {
@@ -105,7 +90,7 @@ function createRequestHeaders(
10590
* Creates a `URL` object from a Node.js `IncomingMessage`, taking into account the protocol, host, and port.
10691
*
10792
* @param nodeRequest - The Node.js `IncomingMessage` or `Http2ServerRequest` object to extract URL information from.
108-
* @param trustProxyHeaders - A boolean or a set of allowed proxy headers.
93+
* @param trustProxyHeaders - A set of allowed proxy headers.
10994
*
11095
* @remarks
11196
* When `trustProxyHeaders` is enabled, headers such as `X-Forwarded-Host` and
@@ -116,7 +101,7 @@ function createRequestHeaders(
116101
*/
117102
export function createRequestUrl(
118103
nodeRequest: IncomingMessage | Http2ServerRequest,
119-
trustProxyHeaders?: boolean | ReadonlySet<string>,
104+
trustProxyHeaders: ReadonlySet<string>,
120105
): URL {
121106
const {
122107
headers,
@@ -154,13 +139,13 @@ export function createRequestUrl(
154139
*
155140
* @param headers - The Node.js incoming HTTP headers.
156141
* @param headerName - The name of the proxy header to retrieve.
157-
* @param trustProxyHeaders - A boolean or a set of allowed proxy headers.
142+
* @param trustProxyHeaders - A set of allowed proxy headers.
158143
* @returns The value of the allowed proxy header, or `undefined` if not allowed or not present.
159144
*/
160145
function getAllowedProxyHeaderValue(
161146
headers: IncomingHttpHeaders,
162147
headerName: string,
163-
trustProxyHeaders: boolean | ReadonlySet<string> | undefined,
148+
trustProxyHeaders: ReadonlySet<string>,
164149
): string | undefined {
165150
return isProxyHeaderAllowed(headerName, trustProxyHeaders)
166151
? getFirstHeaderValue(headers[headerName])
@@ -171,26 +156,9 @@ function getAllowedProxyHeaderValue(
171156
* Checks if a specific proxy header is allowed.
172157
*
173158
* @param headerName - The name of the proxy header to check.
174-
* @param allowedProxyHeaders - A boolean or a set of allowed proxy headers.
159+
* @param allowedProxyHeaders - A set of allowed proxy headers.
175160
* @returns `true` if the header is allowed, `false` otherwise.
176161
*/
177-
function isProxyHeaderAllowed(
178-
headerName: string,
179-
trustProxyHeaders: boolean | ReadonlySet<string> | undefined,
180-
): boolean {
181-
if (trustProxyHeaders === undefined) {
182-
const lower = headerName.toLowerCase();
183-
184-
return lower === 'x-forwarded-host' || lower === 'x-forwarded-proto';
185-
}
186-
187-
if (trustProxyHeaders === false) {
188-
return false;
189-
}
190-
191-
if (trustProxyHeaders === true) {
192-
return true;
193-
}
194-
162+
function isProxyHeaderAllowed(headerName: string, trustProxyHeaders: ReadonlySet<string>): boolean {
195163
return trustProxyHeaders.has(headerName.toLowerCase());
196164
}

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

Lines changed: 15 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.
@@ -97,7 +101,7 @@ export class AngularAppEngine {
97101
/**
98102
* The normalized allowed proxy headers.
99103
*/
100-
private readonly trustProxyHeaders: ReadonlySet<string> | boolean | undefined;
104+
private readonly trustProxyHeaders: ReadonlySet<string>;
101105

102106
/**
103107
* A cache that holds entry points, keyed by their potential locale string.
@@ -110,14 +114,7 @@ export class AngularAppEngine {
110114
*/
111115
constructor(options?: AngularAppEngineOptions) {
112116
this.allowedHosts = this.getAllowedHosts(options);
113-
114-
const trustProxyHeaders = options?.trustProxyHeaders;
115-
this.trustProxyHeaders =
116-
trustProxyHeaders === undefined
117-
? undefined
118-
: typeof trustProxyHeaders === 'boolean'
119-
? trustProxyHeaders
120-
: new Set(trustProxyHeaders.map((h) => h.toLowerCase()));
117+
this.trustProxyHeaders = normalizeTrustProxyHeaders(options?.trustProxyHeaders);
121118
}
122119

123120
private getAllowedHosts(options: AngularAppEngineOptions | undefined): ReadonlySet<string> {
@@ -160,7 +157,10 @@ export class AngularAppEngine {
160157
*/
161158
async handle(request: Request, requestContext?: unknown): Promise<Response | null> {
162159
const allowedHost = this.allowedHosts;
163-
const securedRequest = sanitizeRequestHeaders(request, this.trustProxyHeaders);
160+
const { request: securedRequest, deoptToCSR } = sanitizeRequestHeaders(
161+
request,
162+
this.trustProxyHeaders,
163+
);
164164

165165
try {
166166
validateRequest(securedRequest, allowedHost, AngularAppEngine.ɵdisableAllowedHostsCheck);
@@ -170,6 +170,10 @@ export class AngularAppEngine {
170170

171171
const serverApp = await this.getAngularServerAppForRequest(securedRequest);
172172
if (serverApp) {
173+
if (deoptToCSR) {
174+
return serverApp.serveClientSidePage();
175+
}
176+
173177
return serverApp.handle(securedRequest, requestContext);
174178
}
175179

packages/angular/ssr/src/app.ts

Lines changed: 0 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -191,20 +191,6 @@ export class AngularServerApp {
191191
}
192192

193193
const { redirectTo, status, renderMode, headers, preload } = matchedRoute;
194-
195-
if (request.headers.get('x-angular-deopt-csr') === 'true') {
196-
let html = await this.assets.getServerAsset('index.csr.html').text();
197-
html = await this.runTransformsOnHtml(html, url, preload);
198-
199-
return new Response(html, {
200-
status: 200,
201-
headers: new Headers({
202-
'Content-Type': 'text/html;charset=UTF-8',
203-
...headers,
204-
}),
205-
});
206-
}
207-
208194
if (redirectTo !== undefined) {
209195
return createRedirectResponse(
210196
joinUrlParts(

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

Lines changed: 62 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,17 @@
66
* found in the LICENSE file at https://angular.dev/license
77
*/
88

9+
/**
10+
* Common X-Forwarded-* headers.
11+
*/
12+
const X_FORWARDED_HEADERS = new Set([
13+
'x-forwarded-for',
14+
'x-forwarded-host',
15+
'x-forwarded-port',
16+
'x-forwarded-proto',
17+
'x-forwarded-prefix',
18+
]);
19+
920
/**
1021
* The set of headers that should be validated for host header injection attacks.
1122
*/
@@ -85,49 +96,31 @@ export function validateUrl(url: URL, allowedHosts: ReadonlySet<string>): void {
8596
* If no headers need to be removed, the original request is returned without cloning.
8697
*
8798
* @param request - The incoming `Request` object to sanitize.
88-
* @param trustProxyHeaders - A set of allowed proxy headers or a boolean indicating whether all are allowed.
89-
* @returns The sanitized request, or the original request if no changes were needed.
99+
* @param trustProxyHeaders - A set of allowed proxy headers.
100+
* @returns An object containing the sanitized request, or the original request if no changes were needed.
90101
*/
91102
export function sanitizeRequestHeaders(
92103
request: Request,
93-
trustProxyHeaders: boolean | ReadonlySet<string> | undefined,
94-
): Request {
95-
let receivedXForwarded = false;
104+
trustProxyHeaders: ReadonlySet<string>,
105+
): { request: Request; deoptToCSR: boolean } {
96106
const keysToDelete: string[] = [];
97107
let deoptToCSR = false;
98108

99109
for (const [key] of request.headers) {
100110
const lowerKey = key.toLowerCase();
101-
if (lowerKey.startsWith('x-forwarded-')) {
102-
receivedXForwarded = true;
103-
104-
if (trustProxyHeaders === true) {
105-
continue;
106-
}
107-
108-
if (trustProxyHeaders === undefined) {
109-
if (lowerKey === 'x-forwarded-host' || lowerKey === 'x-forwarded-proto') {
110-
continue;
111-
}
112-
if (lowerKey === 'x-forwarded-prefix') {
113-
deoptToCSR = true;
114-
continue;
115-
}
116-
} else if (trustProxyHeaders !== false && trustProxyHeaders.has(lowerKey)) {
117-
continue;
118-
}
119-
111+
if (lowerKey.startsWith('x-forwarded-') && !isProxyHeaderAllowed(lowerKey, trustProxyHeaders)) {
112+
// eslint-disable-next-line no-console
113+
console.warn(
114+
`Received "${key}" header but "trustProxyHeaders" was not set up to allow it.\n` +
115+
`For more information, see https://angular.dev/best-practices/security#configuring-trusted-proxy-headers`,
116+
);
117+
deoptToCSR = true;
120118
keysToDelete.push(key);
121119
}
122120
}
123121

124-
if (trustProxyHeaders === undefined && receivedXForwarded) {
125-
// eslint-disable-next-line no-console
126-
console.warn('Received "X-Forwarded-*" headers but "trustProxyHeaders" was not configured.');
127-
}
128-
129-
if (keysToDelete.length === 0 && !deoptToCSR) {
130-
return request;
122+
if (keysToDelete.length === 0) {
123+
return { request, deoptToCSR };
131124
}
132125

133126
const clonedReq = new Request(request.clone(), {
@@ -139,11 +132,7 @@ export function sanitizeRequestHeaders(
139132
headers.delete(key);
140133
}
141134

142-
if (deoptToCSR) {
143-
headers.set('x-angular-deopt-csr', 'true');
144-
}
145-
146-
return clonedReq;
135+
return { request: clonedReq, deoptToCSR };
147136
}
148137

149138
/**
@@ -240,3 +229,40 @@ function validateHeaders(
240229
);
241230
}
242231
}
232+
233+
/**
234+
* Checks if a specific proxy header is allowed.
235+
*
236+
* @param headerName - The name of the proxy header to check.
237+
* @param trustProxyHeaders - A set of allowed proxy headers.
238+
* @returns `true` if the header is allowed, `false` otherwise.
239+
*/
240+
export function isProxyHeaderAllowed(
241+
headerName: string,
242+
trustProxyHeaders: ReadonlySet<string>,
243+
): boolean {
244+
return trustProxyHeaders.has(headerName.toLowerCase());
245+
}
246+
247+
/**
248+
* Normalizes the `trustProxyHeaders` option to a consistent representation.
249+
* @param trustProxyHeaders The input `trustProxyHeaders` value.
250+
* @returns A `Set<string>` of normalized header names.
251+
*/
252+
export function normalizeTrustProxyHeaders(
253+
trustProxyHeaders: boolean | readonly string[] | undefined,
254+
): ReadonlySet<string> {
255+
if (trustProxyHeaders === undefined) {
256+
return new Set(['x-forwarded-host', 'x-forwarded-proto']);
257+
}
258+
259+
if (trustProxyHeaders === false) {
260+
return new Set();
261+
}
262+
263+
if (trustProxyHeaders === true) {
264+
return X_FORWARDED_HEADERS;
265+
}
266+
267+
return new Set(trustProxyHeaders.map((h) => h.toLowerCase()));
268+
}

0 commit comments

Comments
 (0)