Skip to content

Commit e769eae

Browse files
committed
fix(@angular/ssr): introduce trustProxyHeaders option to safely validate and sanitize proxy headers
This commit adds the `trustProxyHeaders` option to `AngularAppEngineOptions` and `AngularNodeAppEngineOptions` to configure, validate, and sanitize `X-Forwarded-*` headers. - When `trustProxyHeaders` is `undefined` (default): - Allows `X-Forwarded-Host` and `X-Forwarded-Proto`. - Intercepts `X-Forwarded-Prefix` and triggers a dynamic CSR deoptimization to skip SSR if present. - Logs an informative message when receiving any other `X-Forwarded-*` headers. - When `false`: - Ignores and strips all proxy headers from the request. - When `true`: - Trusts all proxy headers. - When a string array: - Allows only the proxy headers provided inside the array. Example: ```ts const engine = new AngularAppEngine({ // Allow all proxy headers trustProxyHeaders: true, }); // Or explicitly allow specific headers: const engine = new AngularAppEngine({ trustProxyHeaders: ['x-forwarded-host', 'x-forwarded-prefix'], }); ```
1 parent 1883121 commit e769eae

10 files changed

Lines changed: 315 additions & 298 deletions

File tree

goldens/public-api/angular/ssr/index.api.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ export class AngularAppEngine {
2222
// @public
2323
export interface AngularAppEngineOptions {
2424
allowedHosts?: readonly string[];
25+
trustProxyHeaders?: boolean | readonly string[];
2526
}
2627

2728
// @public

goldens/public-api/angular/ssr/node/index.api.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ export interface CommonEngineRenderOptions {
5555
export function createNodeRequestHandler<T extends NodeRequestHandlerFunction>(handler: T): T;
5656

5757
// @public
58-
export function createWebRequestFromNodeRequest(nodeRequest: IncomingMessage | Http2ServerRequest): Request;
58+
export function createWebRequestFromNodeRequest(nodeRequest: IncomingMessage | Http2ServerRequest, trustProxyHeaders?: boolean | readonly string[]): Request;
5959

6060
// @public
6161
export function isMainModule(url: string): boolean;

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

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ export interface AngularNodeAppEngineOptions extends AngularAppEngineOptions {}
2929
*/
3030
export class AngularNodeAppEngine {
3131
private readonly angularAppEngine: AngularAppEngine;
32+
private readonly trustProxyHeaders?: boolean | readonly string[];
3233

3334
/**
3435
* Creates a new instance of the Angular Node.js server application engine.
@@ -39,6 +40,7 @@ export class AngularNodeAppEngine {
3940
...options,
4041
allowedHosts: [...getAllowedHostsFromEnv(), ...(options?.allowedHosts ?? [])],
4142
});
43+
this.trustProxyHeaders = options?.trustProxyHeaders;
4244

4345
attachNodeGlobalErrorHandlers();
4446
}
@@ -76,7 +78,9 @@ export class AngularNodeAppEngine {
7678
requestContext?: unknown,
7779
): Promise<Response | null> {
7880
const webRequest =
79-
request instanceof Request ? request : createWebRequestFromNodeRequest(request);
81+
request instanceof Request
82+
? request
83+
: createWebRequestFromNodeRequest(request, this.trustProxyHeaders);
8084

8185
return this.angularAppEngine.handle(webRequest, requestContext);
8286
}

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

Lines changed: 98 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,13 @@ import { getFirstHeaderValue } from '../../src/utils/validation';
1717
* as they are not allowed to be set directly using the `Node.js` Undici API or
1818
* the web `Headers` API.
1919
*/
20-
const HTTP2_PSEUDO_HEADERS = new Set([':method', ':scheme', ':authority', ':path', ':status']);
20+
const HTTP2_PSEUDO_HEADERS: ReadonlySet<string> = new Set([
21+
':method',
22+
':scheme',
23+
':authority',
24+
':path',
25+
':status',
26+
]);
2127

2228
/**
2329
* Converts a Node.js `IncomingMessage` or `Http2ServerRequest` into a
@@ -27,18 +33,31 @@ const HTTP2_PSEUDO_HEADERS = new Set([':method', ':scheme', ':authority', ':path
2733
* be used by web platform APIs.
2834
*
2935
* @param nodeRequest - The Node.js request object (`IncomingMessage` or `Http2ServerRequest`) to convert.
36+
* @param trustProxyHeaders - A boolean or an array of allowed proxy headers.
37+
*
38+
* @remarks
39+
* When `trustProxyHeaders` is enabled, headers such as `X-Forwarded-Host` and
40+
* `X-Forwarded-Prefix` should ideally be strictly validated at a higher infrastructure
41+
* level (e.g., at the reverse proxy or API gateway) before reaching the application.
42+
*
3043
* @returns A Web Standard `Request` object.
3144
*/
3245
export function createWebRequestFromNodeRequest(
3346
nodeRequest: IncomingMessage | Http2ServerRequest,
47+
trustProxyHeaders?: boolean | readonly string[],
3448
): Request {
49+
const trustProxyHeadersNormalized =
50+
trustProxyHeaders && typeof trustProxyHeaders !== 'boolean'
51+
? new Set(trustProxyHeaders.map((h) => h.toLowerCase()))
52+
: trustProxyHeaders;
53+
3554
const { headers, method = 'GET' } = nodeRequest;
3655
const withBody = method !== 'GET' && method !== 'HEAD';
3756
const referrer = headers.referer && URL.canParse(headers.referer) ? headers.referer : undefined;
3857

39-
return new Request(createRequestUrl(nodeRequest), {
58+
return new Request(createRequestUrl(nodeRequest, trustProxyHeadersNormalized), {
4059
method,
41-
headers: createRequestHeaders(headers),
60+
headers: createRequestHeaders(headers, trustProxyHeadersNormalized),
4261
body: withBody ? nodeRequest : undefined,
4362
duplex: withBody ? 'half' : undefined,
4463
referrer,
@@ -49,16 +68,27 @@ export function createWebRequestFromNodeRequest(
4968
* Creates a `Headers` object from Node.js `IncomingHttpHeaders`.
5069
*
5170
* @param nodeHeaders - The Node.js `IncomingHttpHeaders` object to convert.
71+
* @param trustProxyHeaders - A boolean or a set of allowed proxy headers.
5272
* @returns A `Headers` object containing the converted headers.
5373
*/
54-
function createRequestHeaders(nodeHeaders: IncomingHttpHeaders): Headers {
74+
function createRequestHeaders(
75+
nodeHeaders: IncomingHttpHeaders,
76+
trustProxyHeaders: boolean | ReadonlySet<string> | undefined,
77+
): Headers {
5578
const headers = new Headers();
5679

5780
for (const [name, value] of Object.entries(nodeHeaders)) {
5881
if (HTTP2_PSEUDO_HEADERS.has(name)) {
5982
continue;
6083
}
6184

85+
if (
86+
name.toLowerCase().startsWith('x-forwarded-') &&
87+
!isProxyHeaderAllowed(name.toLowerCase(), trustProxyHeaders)
88+
) {
89+
continue;
90+
}
91+
6292
if (typeof value === 'string') {
6393
headers.append(name, value);
6494
} else if (Array.isArray(value)) {
@@ -75,32 +105,92 @@ function createRequestHeaders(nodeHeaders: IncomingHttpHeaders): Headers {
75105
* Creates a `URL` object from a Node.js `IncomingMessage`, taking into account the protocol, host, and port.
76106
*
77107
* @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.
109+
*
110+
* @remarks
111+
* When `trustProxyHeaders` is enabled, headers such as `X-Forwarded-Host` and
112+
* `X-Forwarded-Prefix` should ideally be strictly validated at a higher infrastructure
113+
* level (e.g., at the reverse proxy or API gateway) before reaching the application.
114+
*
78115
* @returns A `URL` object representing the request URL.
79116
*/
80-
export function createRequestUrl(nodeRequest: IncomingMessage | Http2ServerRequest): URL {
117+
export function createRequestUrl(
118+
nodeRequest: IncomingMessage | Http2ServerRequest,
119+
trustProxyHeaders?: boolean | ReadonlySet<string>,
120+
): URL {
81121
const {
82122
headers,
83123
socket,
84124
url = '',
85125
originalUrl,
86126
} = nodeRequest as IncomingMessage & { originalUrl?: string };
127+
87128
const protocol =
88-
getFirstHeaderValue(headers['x-forwarded-proto']) ??
129+
getAllowedProxyHeaderValue(headers, 'x-forwarded-proto', trustProxyHeaders) ??
89130
('encrypted' in socket && socket.encrypted ? 'https' : 'http');
131+
90132
const hostname =
91-
getFirstHeaderValue(headers['x-forwarded-host']) ?? headers.host ?? headers[':authority'];
133+
getAllowedProxyHeaderValue(headers, 'x-forwarded-host', trustProxyHeaders) ??
134+
headers.host ??
135+
headers[':authority'];
92136

93137
if (Array.isArray(hostname)) {
94138
throw new Error('host value cannot be an array.');
95139
}
96140

97141
let hostnameWithPort = hostname;
98142
if (!hostname?.includes(':')) {
99-
const port = getFirstHeaderValue(headers['x-forwarded-port']);
143+
const port = getAllowedProxyHeaderValue(headers, 'x-forwarded-port', trustProxyHeaders);
100144
if (port) {
101145
hostnameWithPort += `:${port}`;
102146
}
103147
}
104148

105149
return new URL(`${protocol}://${hostnameWithPort}${originalUrl ?? url}`);
106150
}
151+
152+
/**
153+
* Gets the first value of an allowed proxy header.
154+
*
155+
* @param headers - The Node.js incoming HTTP headers.
156+
* @param headerName - The name of the proxy header to retrieve.
157+
* @param trustProxyHeaders - A boolean or a set of allowed proxy headers.
158+
* @returns The value of the allowed proxy header, or `undefined` if not allowed or not present.
159+
*/
160+
function getAllowedProxyHeaderValue(
161+
headers: IncomingHttpHeaders,
162+
headerName: string,
163+
trustProxyHeaders: boolean | ReadonlySet<string> | undefined,
164+
): string | undefined {
165+
return isProxyHeaderAllowed(headerName, trustProxyHeaders)
166+
? getFirstHeaderValue(headers[headerName])
167+
: undefined;
168+
}
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: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,7 @@ describe('createRequestUrl', () => {
137137
},
138138
url: '/test',
139139
}),
140+
true,
140141
);
141142
expect(url.href).toBe('https://example.com/test');
142143
});
@@ -152,6 +153,7 @@ describe('createRequestUrl', () => {
152153
},
153154
url: '/test',
154155
}),
156+
true,
155157
);
156158
expect(url.href).toBe('https://example.com:8443/test');
157159
});

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

Lines changed: 34 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ 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 { cloneRequestAndPatchHeaders, validateRequest } from './utils/validation';
15+
import { sanitizeRequestHeaders, validateRequest } from './utils/validation';
1616

1717
/**
1818
* Options for the Angular server application engine.
@@ -22,6 +22,22 @@ export interface AngularAppEngineOptions {
2222
* A set of allowed hostnames for the server application.
2323
*/
2424
allowedHosts?: readonly string[];
25+
26+
/**
27+
* Extends the scope of trusted proxy headers (`X-Forwarded-*`).
28+
*
29+
* @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.
33+
*
34+
* If a `string[]` is provided, only those proxy headers are allowed.
35+
* If `true`, all proxy headers are allowed.
36+
* If `false`, proxy headers are ignored.
37+
*
38+
* @default undefined
39+
*/
40+
trustProxyHeaders?: boolean | readonly string[];
2541
}
2642

2743
/**
@@ -78,6 +94,11 @@ export class AngularAppEngine {
7894
this.manifest.supportedLocales,
7995
);
8096

97+
/**
98+
* The normalized allowed proxy headers.
99+
*/
100+
private readonly trustProxyHeaders: ReadonlySet<string> | boolean | undefined;
101+
81102
/**
82103
* A cache that holds entry points, keyed by their potential locale string.
83104
*/
@@ -89,6 +110,14 @@ export class AngularAppEngine {
89110
*/
90111
constructor(options?: AngularAppEngineOptions) {
91112
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()));
92121
}
93122

94123
private getAllowedHosts(options: AngularAppEngineOptions | undefined): ReadonlySet<string> {
@@ -131,33 +160,17 @@ export class AngularAppEngine {
131160
*/
132161
async handle(request: Request, requestContext?: unknown): Promise<Response | null> {
133162
const allowedHost = this.allowedHosts;
134-
const disableAllowedHostsCheck = AngularAppEngine.ɵdisableAllowedHostsCheck;
163+
const securedRequest = sanitizeRequestHeaders(request, this.trustProxyHeaders);
135164

136165
try {
137-
validateRequest(request, allowedHost, disableAllowedHostsCheck);
166+
validateRequest(securedRequest, allowedHost, AngularAppEngine.ɵdisableAllowedHostsCheck);
138167
} catch (error) {
139-
return this.handleValidationError(error as Error, request);
168+
return this.handleValidationError(error as Error, securedRequest);
140169
}
141170

142-
// Clone request with patched headers to prevent unallowed host header access.
143-
const { request: securedRequest, onError: onHeaderValidationError } = disableAllowedHostsCheck
144-
? { request, onError: null }
145-
: cloneRequestAndPatchHeaders(request, allowedHost);
146-
147171
const serverApp = await this.getAngularServerAppForRequest(securedRequest);
148172
if (serverApp) {
149-
const promises: Promise<Response | null>[] = [];
150-
if (onHeaderValidationError) {
151-
promises.push(
152-
onHeaderValidationError.then((error) =>
153-
this.handleValidationError(error, securedRequest),
154-
),
155-
);
156-
}
157-
158-
promises.push(serverApp.handle(securedRequest, requestContext));
159-
160-
return Promise.race(promises);
173+
return serverApp.handle(securedRequest, requestContext);
161174
}
162175

163176
if (this.supportedLocales.length > 1) {

packages/angular/ssr/src/app.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -190,7 +190,20 @@ export class AngularServerApp {
190190
return null;
191191
}
192192

193-
const { redirectTo, status, renderMode, headers } = matchedRoute;
193+
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+
}
194207

195208
if (redirectTo !== undefined) {
196209
return createRedirectResponse(

0 commit comments

Comments
 (0)