Skip to content

Commit e41f23e

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 e41f23e

File tree

9 files changed

+215
-296
lines changed

9 files changed

+215
-296
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: 51 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -27,18 +27,25 @@ const HTTP2_PSEUDO_HEADERS = new Set([':method', ':scheme', ':authority', ':path
2727
* be used by web platform APIs.
2828
*
2929
* @param nodeRequest - The Node.js request object (`IncomingMessage` or `Http2ServerRequest`) to convert.
30+
* @param allowedProxyHeaders - A boolean or an array of allowed proxy headers.
3031
* @returns A Web Standard `Request` object.
3132
*/
3233
export function createWebRequestFromNodeRequest(
3334
nodeRequest: IncomingMessage | Http2ServerRequest,
35+
allowedProxyHeaders?: boolean | readonly string[],
3436
): Request {
37+
const allowedProxyHeadersNormalized =
38+
allowedProxyHeaders && typeof allowedProxyHeaders !== 'boolean'
39+
? new Set(allowedProxyHeaders.map((h) => h.toLowerCase()))
40+
: allowedProxyHeaders;
41+
3542
const { headers, method = 'GET' } = nodeRequest;
3643
const withBody = method !== 'GET' && method !== 'HEAD';
3744
const referrer = headers.referer && URL.canParse(headers.referer) ? headers.referer : undefined;
3845

39-
return new Request(createRequestUrl(nodeRequest), {
46+
return new Request(createRequestUrl(nodeRequest, allowedProxyHeadersNormalized), {
4047
method,
41-
headers: createRequestHeaders(headers),
48+
headers: createRequestHeaders(headers, allowedProxyHeadersNormalized),
4249
body: withBody ? nodeRequest : undefined,
4350
duplex: withBody ? 'half' : undefined,
4451
referrer,
@@ -49,16 +56,24 @@ export function createWebRequestFromNodeRequest(
4956
* Creates a `Headers` object from Node.js `IncomingHttpHeaders`.
5057
*
5158
* @param nodeHeaders - The Node.js `IncomingHttpHeaders` object to convert.
59+
* @param allowedProxyHeaders - A boolean or a set of allowed proxy headers.
5260
* @returns A `Headers` object containing the converted headers.
5361
*/
54-
function createRequestHeaders(nodeHeaders: IncomingHttpHeaders): Headers {
62+
function createRequestHeaders(
63+
nodeHeaders: IncomingHttpHeaders,
64+
allowedProxyHeaders: boolean | ReadonlySet<string> | undefined,
65+
): Headers {
5566
const headers = new Headers();
5667

5768
for (const [name, value] of Object.entries(nodeHeaders)) {
5869
if (HTTP2_PSEUDO_HEADERS.has(name)) {
5970
continue;
6071
}
6172

73+
if (name.startsWith('x-forwarded-') && !isProxyHeaderAllowed(name, allowedProxyHeaders)) {
74+
continue;
75+
}
76+
6277
if (typeof value === 'string') {
6378
headers.append(name, value);
6479
} else if (Array.isArray(value)) {
@@ -75,27 +90,37 @@ function createRequestHeaders(nodeHeaders: IncomingHttpHeaders): Headers {
7590
* Creates a `URL` object from a Node.js `IncomingMessage`, taking into account the protocol, host, and port.
7691
*
7792
* @param nodeRequest - The Node.js `IncomingMessage` or `Http2ServerRequest` object to extract URL information from.
93+
* @param allowedProxyHeaders - A boolean or a set of allowed proxy headers.
7894
* @returns A `URL` object representing the request URL.
7995
*/
80-
export function createRequestUrl(nodeRequest: IncomingMessage | Http2ServerRequest): URL {
96+
export function createRequestUrl(
97+
nodeRequest: IncomingMessage | Http2ServerRequest,
98+
allowedProxyHeaders?: boolean | ReadonlySet<string>,
99+
): URL {
81100
const {
82101
headers,
83102
socket,
84103
url = '',
85104
originalUrl,
86105
} = nodeRequest as IncomingMessage & { originalUrl?: string };
106+
87107
const protocol =
88-
getFirstHeaderValue(headers['x-forwarded-proto']) ??
89-
('encrypted' in socket && socket.encrypted ? 'https' : 'http');
108+
(isProxyHeaderAllowed('x-forwarded-proto', allowedProxyHeaders)
109+
? getFirstHeaderValue(headers['x-forwarded-proto'])
110+
: undefined) ?? ('encrypted' in socket && socket.encrypted ? 'https' : 'http');
90111
const hostname =
91-
getFirstHeaderValue(headers['x-forwarded-host']) ?? headers.host ?? headers[':authority'];
112+
(isProxyHeaderAllowed('x-forwarded-host', allowedProxyHeaders)
113+
? getFirstHeaderValue(headers['x-forwarded-host'])
114+
: undefined) ??
115+
headers.host ??
116+
headers[':authority'];
92117

93118
if (Array.isArray(hostname)) {
94119
throw new Error('host value cannot be an array.');
95120
}
96121

97122
let hostnameWithPort = hostname;
98-
if (!hostname?.includes(':')) {
123+
if (!hostname?.includes(':') && isProxyHeaderAllowed('x-forwarded-port', allowedProxyHeaders)) {
99124
const port = getFirstHeaderValue(headers['x-forwarded-port']);
100125
if (port) {
101126
hostnameWithPort += `:${port}`;
@@ -104,3 +129,21 @@ export function createRequestUrl(nodeRequest: IncomingMessage | Http2ServerReque
104129

105130
return new URL(`${protocol}://${hostnameWithPort}${originalUrl ?? url}`);
106131
}
132+
133+
/**
134+
* Checks if a specific proxy header is allowed.
135+
*
136+
* @param headerName - The name of the proxy header to check.
137+
* @param allowedProxyHeaders - A boolean or a set of allowed proxy headers.
138+
* @returns `true` if the header is allowed, `false` otherwise.
139+
*/
140+
function isProxyHeaderAllowed(
141+
headerName: string,
142+
allowedProxyHeaders?: boolean | ReadonlySet<string>,
143+
): boolean {
144+
if (!allowedProxyHeaders) {
145+
return false;
146+
}
147+
148+
return allowedProxyHeaders === true ? true : allowedProxyHeaders.has(headerName.toLowerCase());
149+
}

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: 28 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,18 @@ 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+
* If a `string[]` is provided, only those proxy headers are allowed.
31+
* If `true`, all proxy headers are allowed.
32+
* If `false` or not provided, proxy headers are ignored.
33+
*
34+
* @default false
35+
*/
36+
allowedProxyHeaders?: boolean | readonly string[];
2537
}
2638

2739
/**
@@ -78,6 +90,11 @@ export class AngularAppEngine {
7890
this.manifest.supportedLocales,
7991
);
8092

93+
/**
94+
* The resolved allowed proxy headers.
95+
*/
96+
private readonly allowedProxyHeaders: ReadonlySet<string> | boolean;
97+
8198
/**
8299
* A cache that holds entry points, keyed by their potential locale string.
83100
*/
@@ -89,6 +106,12 @@ export class AngularAppEngine {
89106
*/
90107
constructor(options?: AngularAppEngineOptions) {
91108
this.allowedHosts = this.getAllowedHosts(options);
109+
110+
const allowedProxyHeaders = options?.allowedProxyHeaders ?? false;
111+
this.allowedProxyHeaders =
112+
typeof allowedProxyHeaders === 'boolean'
113+
? allowedProxyHeaders
114+
: new Set(allowedProxyHeaders.map((h) => h.toLowerCase()));
92115
}
93116

94117
private getAllowedHosts(options: AngularAppEngineOptions | undefined): ReadonlySet<string> {
@@ -132,32 +155,17 @@ export class AngularAppEngine {
132155
async handle(request: Request, requestContext?: unknown): Promise<Response | null> {
133156
const allowedHost = this.allowedHosts;
134157
const disableAllowedHostsCheck = AngularAppEngine.ɵdisableAllowedHostsCheck;
158+
const securedRequest = sanitizeRequestHeaders(request, this.allowedProxyHeaders);
135159

136160
try {
137-
validateRequest(request, allowedHost, disableAllowedHostsCheck);
161+
validateRequest(securedRequest, allowedHost, disableAllowedHostsCheck);
138162
} catch (error) {
139-
return this.handleValidationError(request.url, error as Error);
163+
return this.handleValidationError(securedRequest.url, error as Error);
140164
}
141165

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-
147166
const serverApp = await this.getAngularServerAppForRequest(securedRequest);
148167
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);
168+
return serverApp.handle(securedRequest, requestContext);
161169
}
162170

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

0 commit comments

Comments
 (0)