Skip to content
Open
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
2 changes: 2 additions & 0 deletions goldens/public-api/angular/ssr/index.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,14 @@ export class AngularAppEngine {
constructor(options?: AngularAppEngineOptions);
handle(request: Request, requestContext?: unknown): Promise<Response | null>;
static ɵallowStaticRouteRender: boolean;
static ɵdisableAllowedHostsCheck: boolean;
static ɵhooks: Hooks;
}

// @public
export interface AngularAppEngineOptions {
allowedHosts?: readonly string[];
trustProxyHeaders?: 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, trustProxyHeaders?: 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 trustProxyHeaders?: 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.trustProxyHeaders = options?.trustProxyHeaders;

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

return this.angularAppEngine.handle(webRequest, requestContext);
}
Expand Down
65 changes: 58 additions & 7 deletions packages/angular/ssr/node/src/request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,11 @@

import type { IncomingHttpHeaders, IncomingMessage } from 'node:http';
import type { Http2ServerRequest } from 'node:http2';
import { getFirstHeaderValue } from '../../src/utils/validation';
import {
getFirstHeaderValue,
isProxyHeaderAllowed,
normalizeTrustProxyHeaders,
} from '../../src/utils/validation';

/**
* A set containing all the pseudo-headers defined in the HTTP/2 specification.
Expand All @@ -17,7 +21,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,16 +37,25 @@ 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 trustProxyHeaders - A boolean or an array of proxy headers to trust when constructing the request URL.
*
* @remarks
* When `trustProxyHeaders` 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,
trustProxyHeaders?: boolean | readonly string[],
): Request {
const trustProxyHeadersNormalized = normalizeTrustProxyHeaders(trustProxyHeaders);
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, trustProxyHeadersNormalized), {
method,
headers: createRequestHeaders(headers),
body: withBody ? nodeRequest : undefined,
Expand Down Expand Up @@ -75,32 +94,64 @@ 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 trustProxyHeaders - A set of allowed proxy headers.
*
* @remarks
* When `trustProxyHeaders` 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,
trustProxyHeaders: 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', trustProxyHeaders) ??
('encrypted' in socket && socket.encrypted ? 'https' : 'http');

const hostname =
getFirstHeaderValue(headers['x-forwarded-host']) ?? headers.host ?? headers[':authority'];
getAllowedProxyHeaderValue(headers, 'x-forwarded-host', trustProxyHeaders) ??
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', trustProxyHeaders);
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 trustProxyHeaders - 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,
trustProxyHeaders: ReadonlySet<string>,
): string | undefined {
return isProxyHeaderAllowed(headerName, trustProxyHeaders)
? getFirstHeaderValue(headers[headerName])
: undefined;
}
1 change: 1 addition & 0 deletions packages/angular/ssr/node/test/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ ts_project(
srcs = glob(["**/*_spec.ts"]),
deps = [
"//:node_modules/@types/node",
"//packages/angular/ssr",
"//packages/angular/ssr/node",
],
)
Expand Down
24 changes: 11 additions & 13 deletions packages/angular/ssr/node/test/request_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@
*/

import { IncomingMessage } from 'node:http';
import { Http2ServerRequest } from 'node:http2';
import { Socket } from 'node:net';
import { normalizeTrustProxyHeaders } from '../../src/utils/validation';
import { createRequestUrl } from '../src/request';

// Helper to create a mock request object for testing.
Expand All @@ -26,25 +26,14 @@ function createRequest(details: {
} as unknown as IncomingMessage;
}

// Helper to create a mock Http2ServerRequest object for testing.
function createHttp2Request(details: {
headers: Record<string, string | string[] | undefined>;
url?: string;
}): Http2ServerRequest {
return {
headers: details.headers,
socket: new Socket(),
url: details.url,
} as Http2ServerRequest;
}

describe('createRequestUrl', () => {
it('should create a http URL with hostname and port from the host header', () => {
const url = createRequestUrl(
createRequest({
headers: { host: 'localhost:8080' },
url: '/test',
}),
new Set(),
);
expect(url.href).toBe('http://localhost:8080/test');
});
Expand All @@ -56,6 +45,7 @@ describe('createRequestUrl', () => {
encryptedSocket: true,
url: '/test',
}),
new Set(),
);
expect(url.href).toBe('https://example.com/test');
});
Expand All @@ -67,6 +57,7 @@ describe('createRequestUrl', () => {
encryptedSocket: true,
url: '',
}),
new Set(),
);
expect(url.href).toBe('https://example.com/');
});
Expand All @@ -78,6 +69,7 @@ describe('createRequestUrl', () => {
encryptedSocket: true,
url: '/test?a=1',
}),
new Set(),
);
expect(url.href).toBe('https://example.com/test?a=1');
});
Expand All @@ -90,6 +82,7 @@ describe('createRequestUrl', () => {
url: '/test',
originalUrl: '/original',
}),
new Set(),
);
expect(url.href).toBe('https://example.com/original');
});
Expand All @@ -102,6 +95,7 @@ describe('createRequestUrl', () => {
url: undefined,
originalUrl: undefined,
}),
new Set(),
);
expect(url.href).toBe('https://example.com/');
});
Expand All @@ -112,6 +106,7 @@ describe('createRequestUrl', () => {
headers: { host: 'localhost:8080' },
url: '//example.com/test',
}),
new Set(),
);
expect(url.href).toBe('http://localhost:8080//example.com/test');
});
Expand All @@ -123,6 +118,7 @@ describe('createRequestUrl', () => {
url: '/test',
originalUrl: '//example.com/original',
}),
new Set(),
);
expect(url.href).toBe('http://localhost:8080//example.com/original');
});
Expand All @@ -137,6 +133,7 @@ describe('createRequestUrl', () => {
},
url: '/test',
}),
normalizeTrustProxyHeaders(true),
);
expect(url.href).toBe('https://example.com/test');
});
Expand All @@ -152,6 +149,7 @@ describe('createRequestUrl', () => {
},
url: '/test',
}),
normalizeTrustProxyHeaders(true),
);
expect(url.href).toBe('https://example.com:8443/test');
});
Expand Down
Loading
Loading