Skip to content

Commit 429d2e7

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

6 files changed

Lines changed: 115 additions & 135 deletions

File tree

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

Lines changed: 13 additions & 52 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,7 +37,7 @@ 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
@@ -46,18 +50,14 @@ export function createWebRequestFromNodeRequest(
4650
nodeRequest: IncomingMessage | Http2ServerRequest,
4751
trustProxyHeaders?: boolean | readonly string[],
4852
): Request {
49-
const trustProxyHeadersNormalized =
50-
trustProxyHeaders && typeof trustProxyHeaders !== 'boolean'
51-
? new Set(trustProxyHeaders.map((h) => h.toLowerCase()))
52-
: trustProxyHeaders;
53-
53+
const trustProxyHeadersNormalized = normalizeTrustProxyHeaders(trustProxyHeaders);
5454
const { headers, method = 'GET' } = nodeRequest;
5555
const withBody = method !== 'GET' && method !== 'HEAD';
5656
const referrer = headers.referer && URL.canParse(headers.referer) ? headers.referer : undefined;
5757

5858
return new Request(createRequestUrl(nodeRequest, trustProxyHeadersNormalized), {
5959
method,
60-
headers: createRequestHeaders(headers, trustProxyHeadersNormalized),
60+
headers: createRequestHeaders(headers),
6161
body: withBody ? nodeRequest : undefined,
6262
duplex: withBody ? 'half' : undefined,
6363
referrer,
@@ -68,27 +68,16 @@ export function createWebRequestFromNodeRequest(
6868
* Creates a `Headers` object from Node.js `IncomingHttpHeaders`.
6969
*
7070
* @param nodeHeaders - The Node.js `IncomingHttpHeaders` object to convert.
71-
* @param trustProxyHeaders - A boolean or a set of allowed proxy headers.
7271
* @returns A `Headers` object containing the converted headers.
7372
*/
74-
function createRequestHeaders(
75-
nodeHeaders: IncomingHttpHeaders,
76-
trustProxyHeaders: boolean | ReadonlySet<string> | undefined,
77-
): Headers {
73+
function createRequestHeaders(nodeHeaders: IncomingHttpHeaders): Headers {
7874
const headers = new Headers();
7975

8076
for (const [name, value] of Object.entries(nodeHeaders)) {
8177
if (HTTP2_PSEUDO_HEADERS.has(name)) {
8278
continue;
8379
}
8480

85-
if (
86-
name.toLowerCase().startsWith('x-forwarded-') &&
87-
!isProxyHeaderAllowed(name.toLowerCase(), trustProxyHeaders)
88-
) {
89-
continue;
90-
}
91-
9281
if (typeof value === 'string') {
9382
headers.append(name, value);
9483
} else if (Array.isArray(value)) {
@@ -105,7 +94,7 @@ function createRequestHeaders(
10594
* Creates a `URL` object from a Node.js `IncomingMessage`, taking into account the protocol, host, and port.
10695
*
10796
* @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.
97+
* @param trustProxyHeaders - A set of allowed proxy headers.
10998
*
11099
* @remarks
111100
* When `trustProxyHeaders` is enabled, headers such as `X-Forwarded-Host` and
@@ -116,7 +105,7 @@ function createRequestHeaders(
116105
*/
117106
export function createRequestUrl(
118107
nodeRequest: IncomingMessage | Http2ServerRequest,
119-
trustProxyHeaders?: boolean | ReadonlySet<string>,
108+
trustProxyHeaders: ReadonlySet<string>,
120109
): URL {
121110
const {
122111
headers,
@@ -154,43 +143,15 @@ export function createRequestUrl(
154143
*
155144
* @param headers - The Node.js incoming HTTP headers.
156145
* @param headerName - The name of the proxy header to retrieve.
157-
* @param trustProxyHeaders - A boolean or a set of allowed proxy headers.
146+
* @param trustProxyHeaders - A set of allowed proxy headers.
158147
* @returns The value of the allowed proxy header, or `undefined` if not allowed or not present.
159148
*/
160149
function getAllowedProxyHeaderValue(
161150
headers: IncomingHttpHeaders,
162151
headerName: string,
163-
trustProxyHeaders: boolean | ReadonlySet<string> | undefined,
152+
trustProxyHeaders: ReadonlySet<string>,
164153
): string | undefined {
165154
return isProxyHeaderAllowed(headerName, trustProxyHeaders)
166155
? getFirstHeaderValue(headers[headerName])
167156
: undefined;
168157
}
169-
170-
/**
171-
* Checks if a specific proxy header is allowed.
172-
*
173-
* @param headerName - The name of the proxy header to check.
174-
* @param allowedProxyHeaders - A boolean or a set of allowed proxy headers.
175-
* @returns `true` if the header is allowed, `false` otherwise.
176-
*/
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-
195-
return trustProxyHeaders.has(headerName.toLowerCase());
196-
}

packages/angular/ssr/node/test/request_spec.ts

Lines changed: 11 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,9 @@
77
*/
88

99
import { IncomingMessage } from 'node:http';
10-
import { Http2ServerRequest } from 'node:http2';
1110
import { Socket } from 'node:net';
1211
import { createRequestUrl } from '../src/request';
12+
import { normalizeTrustProxyHeaders } from '../../src/utils/validation';
1313

1414
// Helper to create a mock request object for testing.
1515
function createRequest(details: {
@@ -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: 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(

0 commit comments

Comments
 (0)