Skip to content

Commit 812e774

Browse files
committed
refactor(@angular/ssr): enforce explicit opt-in for proxy headers
This commit introduces a secure-by-default model for trusting proxy headers (`X-Forwarded-*`) in the `@angular/ssr` package. Previously, the engine relied on complex lazy header patching and regex filters to guard against spoofed headers. However, implicit decoding behaviors by URL constructors can render naive regex filtering ineffective against certain percent-encoded payloads. To harden the engine against Server-Side Request Forgery (SSRF) and header-spoofing attacks: - Introduced the `allowedProxyHeaders` configuration option to `AngularAppEngineOptions` and `AngularNodeAppEngineOptions`. - By default (`false`), all incoming `X-Forwarded-*` headers are aggressively scrubbed unless explicitly whitelisted via `allowedProxyHeaders`. - Replaced the lazy `cloneRequestAndPatchHeaders` utility with a simplified, eager `sanitizeRequestHeaders` that centralizes the header scrubbing logic. - Hardened `verifyHostAllowed` to definitively reject parsed hosts that successfully carry path, search, hash, or auth components, replacing previously fallible regex filters for stringently checked hosts. BREAKING CHANGE: The `@angular/ssr` package now ignores all `X-Forwarded-*` proxy headers by default. If your application relies on these headers (e.g., for resolving absolute URLs, trust proxy, or custom proxy-related logic), you must explicitly allow them using the new `allowedProxyHeaders` option in the application server configuration. Example: ```ts const engine = new AngularAppEngine({ // Allow all proxy headers allowedProxyHeaders: true, }); // Or explicitly allow specific headers: const engine = new AngularAppEngine({ allowedProxyHeaders: ['x-forwarded-host', 'x-forwarded-prefix'], }); ```
1 parent 3663f80 commit 812e774

File tree

9 files changed

+242
-297
lines changed

9 files changed

+242
-297
lines changed

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+
allowedProxyHeaders?: 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, allowedProxyHeaders?: 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 allowedProxyHeaders?: 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.allowedProxyHeaders = options?.allowedProxyHeaders;
4244

4345
attachNodeGlobalErrorHandlers();
4446
}
@@ -75,7 +77,9 @@ export class AngularNodeAppEngine {
7577
requestContext?: unknown,
7678
): Promise<Response | null> {
7779
const webRequest =
78-
request instanceof Request ? request : createWebRequestFromNodeRequest(request);
80+
request instanceof Request
81+
? request
82+
: createWebRequestFromNodeRequest(request, this.allowedProxyHeaders);
7983

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

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

Lines changed: 74 additions & 9 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 allowedProxyHeaders - A boolean or an array of allowed proxy headers.
37+
*
38+
* @remarks
39+
* When `allowedProxyHeaders` 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+
allowedProxyHeaders?: boolean | readonly string[],
3448
): Request {
49+
const allowedProxyHeadersNormalized =
50+
allowedProxyHeaders && typeof allowedProxyHeaders !== 'boolean'
51+
? new Set(allowedProxyHeaders.map((h) => h.toLowerCase()))
52+
: allowedProxyHeaders;
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, allowedProxyHeadersNormalized), {
4059
method,
41-
headers: createRequestHeaders(headers),
60+
headers: createRequestHeaders(headers, allowedProxyHeadersNormalized),
4261
body: withBody ? nodeRequest : undefined,
4362
duplex: withBody ? 'half' : undefined,
4463
referrer,
@@ -49,16 +68,24 @@ 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 allowedProxyHeaders - 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+
allowedProxyHeaders: 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 (name.startsWith('x-forwarded-') && !isProxyHeaderAllowed(name, allowedProxyHeaders)) {
86+
continue;
87+
}
88+
6289
if (typeof value === 'string') {
6390
headers.append(name, value);
6491
} else if (Array.isArray(value)) {
@@ -75,27 +102,43 @@ function createRequestHeaders(nodeHeaders: IncomingHttpHeaders): Headers {
75102
* Creates a `URL` object from a Node.js `IncomingMessage`, taking into account the protocol, host, and port.
76103
*
77104
* @param nodeRequest - The Node.js `IncomingMessage` or `Http2ServerRequest` object to extract URL information from.
105+
* @param allowedProxyHeaders - A boolean or a set of allowed proxy headers.
106+
*
107+
* @remarks
108+
* When `allowedProxyHeaders` is enabled, headers such as `X-Forwarded-Host` and
109+
* `X-Forwarded-Prefix` should ideally be strictly validated at a higher infrastructure
110+
* level (e.g., at the reverse proxy or API gateway) before reaching the application.
111+
*
78112
* @returns A `URL` object representing the request URL.
79113
*/
80-
export function createRequestUrl(nodeRequest: IncomingMessage | Http2ServerRequest): URL {
114+
export function createRequestUrl(
115+
nodeRequest: IncomingMessage | Http2ServerRequest,
116+
allowedProxyHeaders?: boolean | ReadonlySet<string>,
117+
): URL {
81118
const {
82119
headers,
83120
socket,
84121
url = '',
85122
originalUrl,
86123
} = nodeRequest as IncomingMessage & { originalUrl?: string };
124+
87125
const protocol =
88-
getFirstHeaderValue(headers['x-forwarded-proto']) ??
89-
('encrypted' in socket && socket.encrypted ? 'https' : 'http');
126+
(isProxyHeaderAllowed('x-forwarded-proto', allowedProxyHeaders)
127+
? getFirstHeaderValue(headers['x-forwarded-proto'])
128+
: undefined) ?? ('encrypted' in socket && socket.encrypted ? 'https' : 'http');
90129
const hostname =
91-
getFirstHeaderValue(headers['x-forwarded-host']) ?? headers.host ?? headers[':authority'];
130+
(isProxyHeaderAllowed('x-forwarded-host', allowedProxyHeaders)
131+
? getFirstHeaderValue(headers['x-forwarded-host'])
132+
: undefined) ??
133+
headers.host ??
134+
headers[':authority'];
92135

93136
if (Array.isArray(hostname)) {
94137
throw new Error('host value cannot be an array.');
95138
}
96139

97140
let hostnameWithPort = hostname;
98-
if (!hostname?.includes(':')) {
141+
if (!hostname?.includes(':') && isProxyHeaderAllowed('x-forwarded-port', allowedProxyHeaders)) {
99142
const port = getFirstHeaderValue(headers['x-forwarded-port']);
100143
if (port) {
101144
hostnameWithPort += `:${port}`;
@@ -104,3 +147,25 @@ export function createRequestUrl(nodeRequest: IncomingMessage | Http2ServerReque
104147

105148
return new URL(`${protocol}://${hostnameWithPort}${originalUrl ?? url}`);
106149
}
150+
151+
/**
152+
* Checks if a specific proxy header is allowed.
153+
*
154+
* @param headerName - The name of the proxy header to check.
155+
* @param allowedProxyHeaders - A boolean or a set of allowed proxy headers.
156+
* @returns `true` if the header is allowed, `false` otherwise.
157+
*/
158+
function isProxyHeaderAllowed(
159+
headerName: string,
160+
allowedProxyHeaders?: boolean | ReadonlySet<string>,
161+
): boolean {
162+
if (!allowedProxyHeaders) {
163+
return false;
164+
}
165+
166+
if (allowedProxyHeaders === true) {
167+
return true;
168+
}
169+
170+
return allowedProxyHeaders.has(headerName.toLowerCase());
171+
}

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: 32 additions & 20 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 `allowedProxyHeaders` 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` or not provided, proxy headers are ignored.
37+
*
38+
* @default false
39+
*/
40+
allowedProxyHeaders?: boolean | readonly string[];
2541
}
2642

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

97+
/**
98+
* The resolved allowed proxy headers.
99+
*/
100+
private readonly allowedProxyHeaders: ReadonlySet<string> | boolean;
101+
81102
/**
82103
* A cache that holds entry points, keyed by their potential locale string.
83104
*/
@@ -89,6 +110,12 @@ export class AngularAppEngine {
89110
*/
90111
constructor(options?: AngularAppEngineOptions) {
91112
this.allowedHosts = this.getAllowedHosts(options);
113+
114+
const allowedProxyHeaders = options?.allowedProxyHeaders ?? false;
115+
this.allowedProxyHeaders =
116+
typeof allowedProxyHeaders === 'boolean'
117+
? allowedProxyHeaders
118+
: new Set(allowedProxyHeaders.map((h) => h.toLowerCase()));
92119
}
93120

94121
private getAllowedHosts(options: AngularAppEngineOptions | undefined): ReadonlySet<string> {
@@ -132,32 +159,17 @@ export class AngularAppEngine {
132159
async handle(request: Request, requestContext?: unknown): Promise<Response | null> {
133160
const allowedHost = this.allowedHosts;
134161
const disableAllowedHostsCheck = AngularAppEngine.ɵdisableAllowedHostsCheck;
162+
const securedRequest = sanitizeRequestHeaders(request, this.allowedProxyHeaders);
135163

136164
try {
137-
validateRequest(request, allowedHost, disableAllowedHostsCheck);
165+
validateRequest(securedRequest, allowedHost, disableAllowedHostsCheck);
138166
} catch (error) {
139-
return this.handleValidationError(request.url, error as Error);
167+
return this.handleValidationError(securedRequest.url, error as Error);
140168
}
141169

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-
147170
const serverApp = await this.getAngularServerAppForRequest(securedRequest);
148171
if (serverApp) {
149-
const promises: Promise<Response | null>[] = [];
150-
if (onHeaderValidationError) {
151-
promises.push(
152-
onHeaderValidationError.then((error) =>
153-
this.handleValidationError(securedRequest.url, error),
154-
),
155-
);
156-
}
157-
158-
promises.push(serverApp.handle(securedRequest, requestContext));
159-
160-
return Promise.race(promises);
172+
return serverApp.handle(securedRequest, requestContext);
161173
}
162174

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

0 commit comments

Comments
 (0)