Skip to content

Commit 0f27c3e

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

5 files changed

Lines changed: 99 additions & 100 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: 59 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,28 @@ 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(`Received "${key}" headers but "trustProxyHeaders" was not configured.`);
114+
deoptToCSR = true;
120115
keysToDelete.push(key);
121116
}
122117
}
123118

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

133123
const clonedReq = new Request(request.clone(), {
@@ -139,11 +129,7 @@ export function sanitizeRequestHeaders(
139129
headers.delete(key);
140130
}
141131

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

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

packages/angular/ssr/test/utils/validation_spec.ts

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
import {
1010
getFirstHeaderValue,
11+
normalizeTrustProxyHeaders,
1112
sanitizeRequestHeaders,
1213
validateRequest,
1314
validateUrl,
@@ -224,22 +225,25 @@ describe('Validation Utils', () => {
224225
'x-forwarded-proto': 'https',
225226
},
226227
});
227-
const secured = sanitizeRequestHeaders(req, undefined);
228+
const { request: secured } = sanitizeRequestHeaders(
229+
req,
230+
normalizeTrustProxyHeaders(undefined),
231+
);
228232

229233
expect(secured.headers.get('host')).toBe('example.com');
230234
expect(secured.headers.get('x-forwarded-host')).toBe('proxy.com');
231235
expect(secured.headers.get('x-forwarded-proto')).toBe('https');
232236
});
233237

234-
it('should set x-angular-deopt-csr when x-forwarded-prefix is present and undefined', () => {
238+
it('should set deoptToCSR when x-forwarded-prefix is present and undefined', () => {
235239
const req = new Request('http://example.com', {
236240
headers: {
237241
'x-forwarded-prefix': '/prefix',
238242
},
239243
});
240-
const secured = sanitizeRequestHeaders(req, undefined);
244+
const { deoptToCSR } = sanitizeRequestHeaders(req, normalizeTrustProxyHeaders(undefined));
241245

242-
expect(secured.headers.get('x-angular-deopt-csr')).toBe('true');
246+
expect(deoptToCSR).toBeTrue();
243247
});
244248

245249
it('should retain allowed proxy headers when explicitly provided', () => {
@@ -251,7 +255,7 @@ describe('Validation Utils', () => {
251255
'x-forwarded-proto': 'https',
252256
},
253257
});
254-
const secured = sanitizeRequestHeaders(req, trustProxyHeaders);
258+
const { request: secured } = sanitizeRequestHeaders(req, trustProxyHeaders);
255259

256260
expect(secured.headers.get('host')).toBe('example.com');
257261
expect(secured.headers.get('x-forwarded-host')).toBe('proxy.com');
@@ -266,7 +270,7 @@ describe('Validation Utils', () => {
266270
'x-forwarded-proto': 'https',
267271
},
268272
});
269-
const secured = sanitizeRequestHeaders(req, true);
273+
const { request: secured } = sanitizeRequestHeaders(req, normalizeTrustProxyHeaders(true));
270274

271275
expect(secured.headers.get('host')).toBe('example.com');
272276
expect(secured.headers.get('x-forwarded-host')).toBe('proxy.com');
@@ -277,7 +281,10 @@ describe('Validation Utils', () => {
277281
const req = new Request('http://example.com', {
278282
headers: { 'accept': 'application/json' },
279283
});
280-
const secured = sanitizeRequestHeaders(req, undefined);
284+
const { request: secured } = sanitizeRequestHeaders(
285+
req,
286+
normalizeTrustProxyHeaders(undefined),
287+
);
281288

282289
expect(secured).toBe(req);
283290
expect(secured.headers.get('accept')).toBe('application/json');

0 commit comments

Comments
 (0)