Skip to content

Commit 5382099

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

4 files changed

Lines changed: 83 additions & 93 deletions

File tree

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

Lines changed: 11 additions & 32 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.
@@ -46,11 +46,7 @@ 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;
@@ -68,12 +64,12 @@ 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.
67+
* @param trustProxyHeaders - A set of allowed proxy headers.
7268
* @returns A `Headers` object containing the converted headers.
7369
*/
7470
function createRequestHeaders(
7571
nodeHeaders: IncomingHttpHeaders,
76-
trustProxyHeaders: boolean | ReadonlySet<string> | undefined,
72+
trustProxyHeaders: ReadonlySet<string>,
7773
): Headers {
7874
const headers = new Headers();
7975

@@ -84,7 +80,7 @@ function createRequestHeaders(
8480

8581
if (
8682
name.toLowerCase().startsWith('x-forwarded-') &&
87-
!isProxyHeaderAllowed(name.toLowerCase(), trustProxyHeaders)
83+
!isProxyHeaderAllowed(name, trustProxyHeaders)
8884
) {
8985
continue;
9086
}
@@ -105,7 +101,7 @@ function createRequestHeaders(
105101
* Creates a `URL` object from a Node.js `IncomingMessage`, taking into account the protocol, host, and port.
106102
*
107103
* @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.
104+
* @param trustProxyHeaders - A set of allowed proxy headers.
109105
*
110106
* @remarks
111107
* When `trustProxyHeaders` is enabled, headers such as `X-Forwarded-Host` and
@@ -116,7 +112,7 @@ function createRequestHeaders(
116112
*/
117113
export function createRequestUrl(
118114
nodeRequest: IncomingMessage | Http2ServerRequest,
119-
trustProxyHeaders?: boolean | ReadonlySet<string>,
115+
trustProxyHeaders: ReadonlySet<string>,
120116
): URL {
121117
const {
122118
headers,
@@ -154,13 +150,13 @@ export function createRequestUrl(
154150
*
155151
* @param headers - The Node.js incoming HTTP headers.
156152
* @param headerName - The name of the proxy header to retrieve.
157-
* @param trustProxyHeaders - A boolean or a set of allowed proxy headers.
153+
* @param trustProxyHeaders - A set of allowed proxy headers.
158154
* @returns The value of the allowed proxy header, or `undefined` if not allowed or not present.
159155
*/
160156
function getAllowedProxyHeaderValue(
161157
headers: IncomingHttpHeaders,
162158
headerName: string,
163-
trustProxyHeaders: boolean | ReadonlySet<string> | undefined,
159+
trustProxyHeaders: ReadonlySet<string>,
164160
): string | undefined {
165161
return isProxyHeaderAllowed(headerName, trustProxyHeaders)
166162
? getFirstHeaderValue(headers[headerName])
@@ -171,26 +167,9 @@ function getAllowedProxyHeaderValue(
171167
* Checks if a specific proxy header is allowed.
172168
*
173169
* @param headerName - The name of the proxy header to check.
174-
* @param allowedProxyHeaders - A boolean or a set of allowed proxy headers.
170+
* @param allowedProxyHeaders - A set of allowed proxy headers.
175171
* @returns `true` if the header is allowed, `false` otherwise.
176172
*/
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-
173+
function isProxyHeaderAllowed(headerName: string, trustProxyHeaders: ReadonlySet<string>): boolean {
195174
return trustProxyHeaders.has(headerName.toLowerCase());
196175
}

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: 57 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,27 @@ 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+
console.warn(`Received "${key}" headers but "trustProxyHeaders" was not configured.`);
113+
deoptToCSR = true;
120114
keysToDelete.push(key);
121115
}
122116
}
123117

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;
118+
if (keysToDelete.length === 0) {
119+
return { request, deoptToCSR };
131120
}
132121

133122
const clonedReq = new Request(request.clone(), {
@@ -139,11 +128,7 @@ export function sanitizeRequestHeaders(
139128
headers.delete(key);
140129
}
141130

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

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

0 commit comments

Comments
 (0)