Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions goldens/public-api/angular/ssr/index.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export class AngularAppEngine {
// @public
export interface AngularAppEngineOptions {
allowedHosts?: readonly string[];
allowedProxyHeaders?: boolean | readonly string[];
}

// @public
Expand Down
2 changes: 1 addition & 1 deletion goldens/public-api/angular/ssr/node/index.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ export interface CommonEngineRenderOptions {
export function createNodeRequestHandler<T extends NodeRequestHandlerFunction>(handler: T): T;

// @public
export function createWebRequestFromNodeRequest(nodeRequest: IncomingMessage | Http2ServerRequest): Request;
export function createWebRequestFromNodeRequest(nodeRequest: IncomingMessage | Http2ServerRequest, allowedProxyHeaders?: boolean | readonly string[]): Request;

// @public
export function isMainModule(url: string): boolean;
Expand Down
6 changes: 5 additions & 1 deletion packages/angular/ssr/node/src/app-engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export interface AngularNodeAppEngineOptions extends AngularAppEngineOptions {}
*/
export class AngularNodeAppEngine {
private readonly angularAppEngine: AngularAppEngine;
private readonly allowedProxyHeaders?: boolean | readonly string[];

/**
* Creates a new instance of the Angular Node.js server application engine.
Expand All @@ -39,6 +40,7 @@ export class AngularNodeAppEngine {
...options,
allowedHosts: [...getAllowedHostsFromEnv(), ...(options?.allowedHosts ?? [])],
});
this.allowedProxyHeaders = options?.allowedProxyHeaders;

attachNodeGlobalErrorHandlers();
}
Expand Down Expand Up @@ -75,7 +77,9 @@ export class AngularNodeAppEngine {
requestContext?: unknown,
): Promise<Response | null> {
const webRequest =
request instanceof Request ? request : createWebRequestFromNodeRequest(request);
request instanceof Request
? request
: createWebRequestFromNodeRequest(request, this.allowedProxyHeaders);

return this.angularAppEngine.handle(webRequest, requestContext);
}
Expand Down
97 changes: 89 additions & 8 deletions packages/angular/ssr/node/src/request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,13 @@ import { getFirstHeaderValue } from '../../src/utils/validation';
* as they are not allowed to be set directly using the `Node.js` Undici API or
* the web `Headers` API.
*/
const HTTP2_PSEUDO_HEADERS = new Set([':method', ':scheme', ':authority', ':path', ':status']);
const HTTP2_PSEUDO_HEADERS: ReadonlySet<string> = new Set([
':method',
':scheme',
':authority',
':path',
':status',
]);

/**
* Converts a Node.js `IncomingMessage` or `Http2ServerRequest` into a
Expand All @@ -27,18 +33,31 @@ const HTTP2_PSEUDO_HEADERS = new Set([':method', ':scheme', ':authority', ':path
* be used by web platform APIs.
*
* @param nodeRequest - The Node.js request object (`IncomingMessage` or `Http2ServerRequest`) to convert.
* @param allowedProxyHeaders - A boolean or an array of allowed proxy headers.
*
* @remarks
* When `allowedProxyHeaders` is enabled, headers such as `X-Forwarded-Host` and
* `X-Forwarded-Prefix` should ideally be strictly validated at a higher infrastructure
* level (e.g., at the reverse proxy or API gateway) before reaching the application.
*
* @returns A Web Standard `Request` object.
*/
export function createWebRequestFromNodeRequest(
nodeRequest: IncomingMessage | Http2ServerRequest,
allowedProxyHeaders?: boolean | readonly string[],
): Request {
const allowedProxyHeadersNormalized =
allowedProxyHeaders && typeof allowedProxyHeaders !== 'boolean'
? new Set(allowedProxyHeaders.map((h) => h.toLowerCase()))
: allowedProxyHeaders;

const { headers, method = 'GET' } = nodeRequest;
const withBody = method !== 'GET' && method !== 'HEAD';
const referrer = headers.referer && URL.canParse(headers.referer) ? headers.referer : undefined;

return new Request(createRequestUrl(nodeRequest), {
return new Request(createRequestUrl(nodeRequest, allowedProxyHeadersNormalized), {
method,
headers: createRequestHeaders(headers),
headers: createRequestHeaders(headers, allowedProxyHeadersNormalized),
body: withBody ? nodeRequest : undefined,
duplex: withBody ? 'half' : undefined,
referrer,
Expand All @@ -49,16 +68,24 @@ export function createWebRequestFromNodeRequest(
* Creates a `Headers` object from Node.js `IncomingHttpHeaders`.
*
* @param nodeHeaders - The Node.js `IncomingHttpHeaders` object to convert.
* @param allowedProxyHeaders - A boolean or a set of allowed proxy headers.
* @returns A `Headers` object containing the converted headers.
*/
function createRequestHeaders(nodeHeaders: IncomingHttpHeaders): Headers {
function createRequestHeaders(
nodeHeaders: IncomingHttpHeaders,
allowedProxyHeaders: boolean | ReadonlySet<string> | undefined,
): Headers {
const headers = new Headers();

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

if (name.startsWith('x-forwarded-') && !isProxyHeaderAllowed(name, allowedProxyHeaders)) {
continue;
}

if (typeof value === 'string') {
headers.append(name, value);
} else if (Array.isArray(value)) {
Expand All @@ -75,32 +102,86 @@ function createRequestHeaders(nodeHeaders: IncomingHttpHeaders): Headers {
* Creates a `URL` object from a Node.js `IncomingMessage`, taking into account the protocol, host, and port.
*
* @param nodeRequest - The Node.js `IncomingMessage` or `Http2ServerRequest` object to extract URL information from.
* @param allowedProxyHeaders - A boolean or a set of allowed proxy headers.
*
* @remarks
* When `allowedProxyHeaders` is enabled, headers such as `X-Forwarded-Host` and
* `X-Forwarded-Prefix` should ideally be strictly validated at a higher infrastructure
* level (e.g., at the reverse proxy or API gateway) before reaching the application.
*
* @returns A `URL` object representing the request URL.
*/
export function createRequestUrl(nodeRequest: IncomingMessage | Http2ServerRequest): URL {
export function createRequestUrl(
nodeRequest: IncomingMessage | Http2ServerRequest,
allowedProxyHeaders?: boolean | ReadonlySet<string>,
): URL {
const {
headers,
socket,
url = '',
originalUrl,
} = nodeRequest as IncomingMessage & { originalUrl?: string };

const protocol =
getFirstHeaderValue(headers['x-forwarded-proto']) ??
getAllowedProxyHeaderValue(headers, 'x-forwarded-proto', allowedProxyHeaders) ??
('encrypted' in socket && socket.encrypted ? 'https' : 'http');

const hostname =
getFirstHeaderValue(headers['x-forwarded-host']) ?? headers.host ?? headers[':authority'];
getAllowedProxyHeaderValue(headers, 'x-forwarded-host', allowedProxyHeaders) ??
headers.host ??
headers[':authority'];

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

let hostnameWithPort = hostname;
if (!hostname?.includes(':')) {
const port = getFirstHeaderValue(headers['x-forwarded-port']);
const port = getAllowedProxyHeaderValue(headers, 'x-forwarded-port', allowedProxyHeaders);
if (port) {
hostnameWithPort += `:${port}`;
}
}

return new URL(`${protocol}://${hostnameWithPort}${originalUrl ?? url}`);
}

/**
* Gets the first value of an allowed proxy header.
*
* @param headers - The Node.js incoming HTTP headers.
* @param headerName - The name of the proxy header to retrieve.
* @param allowedProxyHeaders - A boolean or a set of allowed proxy headers.
* @returns The value of the allowed proxy header, or `undefined` if not allowed or not present.
*/
function getAllowedProxyHeaderValue(
headers: IncomingHttpHeaders,
headerName: string,
allowedProxyHeaders: boolean | ReadonlySet<string> | undefined,
): string | undefined {
return isProxyHeaderAllowed(headerName, allowedProxyHeaders)
? getFirstHeaderValue(headers[headerName])
: undefined;
}

/**
* Checks if a specific proxy header is allowed.
*
* @param headerName - The name of the proxy header to check.
* @param allowedProxyHeaders - A boolean or a set of allowed proxy headers.
* @returns `true` if the header is allowed, `false` otherwise.
*/
function isProxyHeaderAllowed(
headerName: string,
allowedProxyHeaders: boolean | ReadonlySet<string> | undefined,
): boolean {
if (!allowedProxyHeaders) {
return false;
}

if (allowedProxyHeaders === true) {
return true;
}

return allowedProxyHeaders.has(headerName.toLowerCase());
}
2 changes: 2 additions & 0 deletions packages/angular/ssr/node/test/request_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,7 @@ describe('createRequestUrl', () => {
},
url: '/test',
}),
true,
);
expect(url.href).toBe('https://example.com/test');
});
Expand All @@ -152,6 +153,7 @@ describe('createRequestUrl', () => {
},
url: '/test',
}),
true,
);
expect(url.href).toBe('https://example.com:8443/test');
});
Expand Down
53 changes: 32 additions & 21 deletions packages/angular/ssr/src/app-engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { getPotentialLocaleIdFromUrl, getPreferredLocale } from './i18n';
import { EntryPointExports, getAngularAppEngineManifest } from './manifest';
import { createRedirectResponse } from './utils/redirect';
import { joinUrlParts } from './utils/url';
import { cloneRequestAndPatchHeaders, validateRequest } from './utils/validation';
import { sanitizeRequestHeaders, validateRequest } from './utils/validation';

/**
* Options for the Angular server application engine.
Expand All @@ -22,6 +22,22 @@ export interface AngularAppEngineOptions {
* A set of allowed hostnames for the server application.
*/
allowedHosts?: readonly string[];

/**
* Extends the scope of trusted proxy headers (`X-Forwarded-*`).
*
* @remarks
* When `allowedProxyHeaders` is enabled, headers such as `X-Forwarded-Host` and
* `X-Forwarded-Prefix` should ideally be strictly validated at a higher infrastructure
* level (e.g., at the reverse proxy or API gateway) before reaching the application.
*
* If a `string[]` is provided, only those proxy headers are allowed.
* If `true`, all proxy headers are allowed.
* If `false` or not provided, proxy headers are ignored.
*
* @default false
*/
allowedProxyHeaders?: boolean | readonly string[];
}

/**
Expand Down Expand Up @@ -78,6 +94,11 @@ export class AngularAppEngine {
this.manifest.supportedLocales,
);

/**
* The normalized allowed proxy headers.
*/
private readonly allowedProxyHeaders: ReadonlySet<string> | boolean;

/**
* A cache that holds entry points, keyed by their potential locale string.
*/
Expand All @@ -89,6 +110,12 @@ export class AngularAppEngine {
*/
constructor(options?: AngularAppEngineOptions) {
this.allowedHosts = this.getAllowedHosts(options);

const allowedProxyHeaders = options?.allowedProxyHeaders ?? false;
this.allowedProxyHeaders =
typeof allowedProxyHeaders === 'boolean'
? allowedProxyHeaders
: new Set(allowedProxyHeaders.map((h) => h.toLowerCase()));
}

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

try {
validateRequest(request, allowedHost, disableAllowedHostsCheck);
validateRequest(securedRequest, allowedHost, AngularAppEngine.ɵdisableAllowedHostsCheck);
} catch (error) {
return this.handleValidationError(request.url, error as Error);
return this.handleValidationError(securedRequest.url, error as Error);
}

// Clone request with patched headers to prevent unallowed host header access.
const { request: securedRequest, onError: onHeaderValidationError } = disableAllowedHostsCheck
? { request, onError: null }
: cloneRequestAndPatchHeaders(request, allowedHost);

const serverApp = await this.getAngularServerAppForRequest(securedRequest);
if (serverApp) {
const promises: Promise<Response | null>[] = [];
if (onHeaderValidationError) {
promises.push(
onHeaderValidationError.then((error) =>
this.handleValidationError(securedRequest.url, error),
),
);
}

promises.push(serverApp.handle(securedRequest, requestContext));

return Promise.race(promises);
return serverApp.handle(securedRequest, requestContext);
}

if (this.supportedLocales.length > 1) {
Expand Down
Loading
Loading