Skip to content

Commit ec466cb

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 ec466cb

File tree

9 files changed

+207
-294
lines changed

9 files changed

+207
-294
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: 43 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -27,16 +27,18 @@ 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 {
3537
const { headers, method = 'GET' } = nodeRequest;
3638
const withBody = method !== 'GET' && method !== 'HEAD';
3739
const referrer = headers.referer && URL.canParse(headers.referer) ? headers.referer : undefined;
3840

39-
return new Request(createRequestUrl(nodeRequest), {
41+
return new Request(createRequestUrl(nodeRequest, allowedProxyHeaders), {
4042
method,
4143
headers: createRequestHeaders(headers),
4244
body: withBody ? nodeRequest : undefined,
@@ -75,32 +77,67 @@ function createRequestHeaders(nodeHeaders: IncomingHttpHeaders): Headers {
7577
* Creates a `URL` object from a Node.js `IncomingMessage`, taking into account the protocol, host, and port.
7678
*
7779
* @param nodeRequest - The Node.js `IncomingMessage` or `Http2ServerRequest` object to extract URL information from.
80+
* @param allowedProxyHeaders - A boolean or an array of allowed proxy headers.
7881
* @returns A `URL` object representing the request URL.
7982
*/
80-
export function createRequestUrl(nodeRequest: IncomingMessage | Http2ServerRequest): URL {
83+
export function createRequestUrl(
84+
nodeRequest: IncomingMessage | Http2ServerRequest,
85+
allowedProxyHeaders?: boolean | readonly string[],
86+
): URL {
8187
const {
8288
headers,
8389
socket,
8490
url = '',
8591
originalUrl,
8692
} = nodeRequest as IncomingMessage & { originalUrl?: string };
93+
94+
const allowedProxyHeadersNormalized =
95+
allowedProxyHeaders && typeof allowedProxyHeaders !== 'boolean'
96+
? new Set(allowedProxyHeaders.map((h) => h.toLowerCase()))
97+
: allowedProxyHeaders;
98+
8799
const protocol =
88-
getFirstHeaderValue(headers['x-forwarded-proto']) ??
89-
('encrypted' in socket && socket.encrypted ? 'https' : 'http');
100+
(isProxyHeaderAllowed('x-forwarded-proto', allowedProxyHeadersNormalized)
101+
? getFirstHeaderValue(headers['x-forwarded-proto'])
102+
: undefined) ?? ('encrypted' in socket && socket.encrypted ? 'https' : 'http');
90103
const hostname =
91-
getFirstHeaderValue(headers['x-forwarded-host']) ?? headers.host ?? headers[':authority'];
104+
(isProxyHeaderAllowed('x-forwarded-host', allowedProxyHeadersNormalized)
105+
? getFirstHeaderValue(headers['x-forwarded-host'])
106+
: undefined) ??
107+
headers.host ??
108+
headers[':authority'];
92109

93110
if (Array.isArray(hostname)) {
94111
throw new Error('host value cannot be an array.');
95112
}
96113

97114
let hostnameWithPort = hostname;
98115
if (!hostname?.includes(':')) {
99-
const port = getFirstHeaderValue(headers['x-forwarded-port']);
116+
const port = isProxyHeaderAllowed('x-forwarded-port', allowedProxyHeadersNormalized)
117+
? getFirstHeaderValue(headers['x-forwarded-port'])
118+
: undefined;
100119
if (port) {
101120
hostnameWithPort += `:${port}`;
102121
}
103122
}
104123

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

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)