Skip to content

Commit 50ad11b

Browse files
committed
fix(@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 `trustProxyHeaders`. - 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 `trustProxyHeaders` option in the application server configuration. 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 a4f11c1 commit 50ad11b

9 files changed

Lines changed: 264 additions & 302 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+
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 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
}
@@ -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.trustProxyHeaders);
7983

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

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

Lines changed: 94 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,33 @@ 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.
44+
*
45+
* @private
3146
*/
3247
export function createWebRequestFromNodeRequest(
3348
nodeRequest: IncomingMessage | Http2ServerRequest,
49+
trustProxyHeaders?: boolean | readonly string[],
3450
): Request {
51+
const trustProxyHeadersNormalized =
52+
trustProxyHeaders && typeof trustProxyHeaders !== 'boolean'
53+
? new Set(trustProxyHeaders.map((h) => h.toLowerCase()))
54+
: trustProxyHeaders;
55+
3556
const { headers, method = 'GET' } = nodeRequest;
3657
const withBody = method !== 'GET' && method !== 'HEAD';
3758
const referrer = headers.referer && URL.canParse(headers.referer) ? headers.referer : undefined;
3859

39-
return new Request(createRequestUrl(nodeRequest), {
60+
return new Request(createRequestUrl(nodeRequest, trustProxyHeadersNormalized), {
4061
method,
41-
headers: createRequestHeaders(headers),
62+
headers: createRequestHeaders(headers, trustProxyHeadersNormalized),
4263
body: withBody ? nodeRequest : undefined,
4364
duplex: withBody ? 'half' : undefined,
4465
referrer,
@@ -49,16 +70,27 @@ export function createWebRequestFromNodeRequest(
4970
* Creates a `Headers` object from Node.js `IncomingHttpHeaders`.
5071
*
5172
* @param nodeHeaders - The Node.js `IncomingHttpHeaders` object to convert.
73+
* @param trustProxyHeaders - A boolean or a set of allowed proxy headers.
5274
* @returns A `Headers` object containing the converted headers.
5375
*/
54-
function createRequestHeaders(nodeHeaders: IncomingHttpHeaders): Headers {
76+
function createRequestHeaders(
77+
nodeHeaders: IncomingHttpHeaders,
78+
trustProxyHeaders: boolean | ReadonlySet<string> | undefined,
79+
): Headers {
5580
const headers = new Headers();
5681

5782
for (const [name, value] of Object.entries(nodeHeaders)) {
5883
if (HTTP2_PSEUDO_HEADERS.has(name)) {
5984
continue;
6085
}
6186

87+
if (
88+
name.toLowerCase().startsWith('x-forwarded-') &&
89+
!isProxyHeaderAllowed(name.toLowerCase(), trustProxyHeaders)
90+
) {
91+
continue;
92+
}
93+
6294
if (typeof value === 'string') {
6395
headers.append(name, value);
6496
} else if (Array.isArray(value)) {
@@ -75,32 +107,86 @@ function createRequestHeaders(nodeHeaders: IncomingHttpHeaders): Headers {
75107
* Creates a `URL` object from a Node.js `IncomingMessage`, taking into account the protocol, host, and port.
76108
*
77109
* @param nodeRequest - The Node.js `IncomingMessage` or `Http2ServerRequest` object to extract URL information from.
110+
* @param trustProxyHeaders - A boolean or a set of allowed proxy headers.
111+
*
112+
* @remarks
113+
* When `trustProxyHeaders` is enabled, headers such as `X-Forwarded-Host` and
114+
* `X-Forwarded-Prefix` should ideally be strictly validated at a higher infrastructure
115+
* level (e.g., at the reverse proxy or API gateway) before reaching the application.
116+
*
78117
* @returns A `URL` object representing the request URL.
79118
*/
80-
export function createRequestUrl(nodeRequest: IncomingMessage | Http2ServerRequest): URL {
119+
export function createRequestUrl(
120+
nodeRequest: IncomingMessage | Http2ServerRequest,
121+
trustProxyHeaders?: boolean | ReadonlySet<string>,
122+
): URL {
81123
const {
82124
headers,
83125
socket,
84126
url = '',
85127
originalUrl,
86128
} = nodeRequest as IncomingMessage & { originalUrl?: string };
129+
87130
const protocol =
88-
getFirstHeaderValue(headers['x-forwarded-proto']) ??
131+
getAllowedProxyHeaderValue(headers, 'x-forwarded-proto', trustProxyHeaders) ??
89132
('encrypted' in socket && socket.encrypted ? 'https' : 'http');
133+
90134
const hostname =
91-
getFirstHeaderValue(headers['x-forwarded-host']) ?? headers.host ?? headers[':authority'];
135+
getAllowedProxyHeaderValue(headers, 'x-forwarded-host', trustProxyHeaders) ??
136+
headers.host ??
137+
headers[':authority'];
92138

93139
if (Array.isArray(hostname)) {
94140
throw new Error('host value cannot be an array.');
95141
}
96142

97143
let hostnameWithPort = hostname;
98144
if (!hostname?.includes(':')) {
99-
const port = getFirstHeaderValue(headers['x-forwarded-port']);
145+
const port = getAllowedProxyHeaderValue(headers, 'x-forwarded-port', trustProxyHeaders);
100146
if (port) {
101147
hostnameWithPort += `:${port}`;
102148
}
103149
}
104150

105151
return new URL(`${protocol}://${hostnameWithPort}${originalUrl ?? url}`);
106152
}
153+
154+
/**
155+
* Gets the first value of an allowed proxy header.
156+
*
157+
* @param headers - The Node.js incoming HTTP headers.
158+
* @param headerName - The name of the proxy header to retrieve.
159+
* @param trustProxyHeaders - A boolean or a set of allowed proxy headers.
160+
* @returns The value of the allowed proxy header, or `undefined` if not allowed or not present.
161+
*/
162+
function getAllowedProxyHeaderValue(
163+
headers: IncomingHttpHeaders,
164+
headerName: string,
165+
trustProxyHeaders: boolean | ReadonlySet<string> | undefined,
166+
): string | undefined {
167+
return isProxyHeaderAllowed(headerName, trustProxyHeaders)
168+
? getFirstHeaderValue(headers[headerName])
169+
: undefined;
170+
}
171+
172+
/**
173+
* Checks if a specific proxy header is allowed.
174+
*
175+
* @param headerName - The name of the proxy header to check.
176+
* @param trustProxyHeaders - A boolean or a set of allowed proxy headers.
177+
* @returns `true` if the header is allowed, `false` otherwise.
178+
*/
179+
function isProxyHeaderAllowed(
180+
headerName: string,
181+
trustProxyHeaders: boolean | ReadonlySet<string> | undefined,
182+
): boolean {
183+
if (!trustProxyHeaders) {
184+
return false;
185+
}
186+
187+
if (trustProxyHeaders === true) {
188+
return true;
189+
}
190+
191+
return trustProxyHeaders.has(headerName.toLowerCase());
192+
}

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 & 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` or not provided, proxy headers are ignored.
37+
*
38+
* @default false
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;
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 trustProxyHeaders = options?.trustProxyHeaders ?? false;
115+
this.trustProxyHeaders =
116+
typeof trustProxyHeaders === 'boolean'
117+
? trustProxyHeaders
118+
: new Set(trustProxyHeaders.map((h) => h.toLowerCase()));
92119
}
93120

94121
private getAllowedHosts(options: AngularAppEngineOptions | undefined): ReadonlySet<string> {
@@ -131,33 +158,17 @@ export class AngularAppEngine {
131158
*/
132159
async handle(request: Request, requestContext?: unknown): Promise<Response | null> {
133160
const allowedHost = this.allowedHosts;
134-
const disableAllowedHostsCheck = AngularAppEngine.ɵdisableAllowedHostsCheck;
161+
const securedRequest = sanitizeRequestHeaders(request, this.trustProxyHeaders);
135162

136163
try {
137-
validateRequest(request, allowedHost, disableAllowedHostsCheck);
164+
validateRequest(securedRequest, allowedHost, AngularAppEngine.ɵdisableAllowedHostsCheck);
138165
} catch (error) {
139-
return this.handleValidationError(request.url, error as Error);
166+
return this.handleValidationError(securedRequest.url, error as Error);
140167
}
141168

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-
147169
const serverApp = await this.getAngularServerAppForRequest(securedRequest);
148170
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);
171+
return serverApp.handle(securedRequest, requestContext);
161172
}
162173

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

0 commit comments

Comments
 (0)