From 3c7678c032ed872640e7b9896204942a32c24962 Mon Sep 17 00:00:00 2001 From: Alan Agius <17563226+alan-agius4@users.noreply.github.com> Date: Tue, 17 Feb 2026 12:37:52 +0000 Subject: [PATCH 1/2] fix(@angular/ssr): validate host headers to prevent header-based SSRF MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This change introduces strict validation for `Host`, `X-Forwarded-Host`, `X-Forwarded-Proto`, and `X-Forwarded-Port` headers in the Angular SSR request handling pipeline, including `CommonEngine` and `AngularAppEngine`. Previously, the application engine constructed the base URL for server-side rendering using these headers without validation. This could allow an attacker to manipulate the headers to steer relative `HttpClient` requests to arbitrary internal or external hosts (SSRF). With this change: - The `Host` and `X-Forwarded-Host` headers are validated against a strict allowlist. - `localhost` and loopback addresses (e.g., `127.0.0.1`, `[::1]`) are allowed by default. - `X-Forwarded-Port` must be numeric. - `X-Forwarded-Proto` must be `http` or `https`. - Requests with invalid or disallowed headers will now be rejected with a `400 Bad Request` status code. BREAKING CHANGE: Server-side requests will now fail with a `400 Bad Request` error if the `Host` header does not match a customized allowlist (or localhost). **AngularAppEngine Users:** To resolve this, you must configure the `allowedHosts` option in your `angular.json` to include all domain names where your application is deployed. Example configuration in `angular.json`: ```json "architect": { "build": { "options": { "security": { "allowedHosts": ["example.com", "*.trusted-example.com"] } } } } ``` **CommonEngine Users:** If you are using `CommonEngine`, you must now provide the `allowedHosts` option when initializing or rendering your application. Example: ```typescript const commonEngine = new CommonEngine({ allowedHosts: [“example.com”, “*.trusted-example.com"] }); ``` --- .../public-api/angular/ssr/node/index.api.md | 3 +- .../src/builders/application/execute-build.ts | 3 +- .../build/src/builders/application/options.ts | 3 +- .../src/builders/application/schema.json | 8 + .../src/builders/dev-server/vite/index.ts | 3 +- .../src/utils/server-rendering/manifest.ts | 7 + .../node/src/common-engine/common-engine.ts | 51 ++++- packages/angular/ssr/node/src/request.ts | 19 +- packages/angular/ssr/src/app-engine.ts | 23 ++- packages/angular/ssr/src/manifest.ts | 6 +- packages/angular/ssr/src/utils/headers.ts | 156 +++++++++++++++ packages/angular/ssr/test/app-engine_spec.ts | 56 ++++++ .../angular/ssr/test/utils/headers_spec.ts | 181 ++++++++++++++++++ .../ssr-dev-server/specs/proxy_spec.ts | 5 +- .../builders/ssr-dev-server/specs/ssl_spec.ts | 5 +- .../ssr-dev-server/specs/works_spec.ts | 5 +- .../files/server-builder/server.ts.template | 4 +- packages/schematics/angular/ssr/index.ts | 6 +- .../e2e/assets/ssr-project-webpack/server.ts | 2 +- tests/e2e/utils/project.ts | 2 +- 20 files changed, 512 insertions(+), 36 deletions(-) create mode 100644 packages/angular/ssr/src/utils/headers.ts create mode 100644 packages/angular/ssr/test/utils/headers_spec.ts diff --git a/goldens/public-api/angular/ssr/node/index.api.md b/goldens/public-api/angular/ssr/node/index.api.md index eccb6396938e..735cb4616ad3 100644 --- a/goldens/public-api/angular/ssr/node/index.api.md +++ b/goldens/public-api/angular/ssr/node/index.api.md @@ -21,12 +21,13 @@ export class AngularNodeAppEngine { // @public export class CommonEngine { - constructor(options?: CommonEngineOptions | undefined); + constructor(options: CommonEngineOptions); render(opts: CommonEngineRenderOptions): Promise; } // @public (undocumented) export interface CommonEngineOptions { + allowedHosts: readonly string[]; bootstrap?: Type<{}> | ((context: BootstrapContext) => Promise); enablePerformanceProfiler?: boolean; providers?: StaticProvider[]; diff --git a/packages/angular/build/src/builders/application/execute-build.ts b/packages/angular/build/src/builders/application/execute-build.ts index 0654cd965558..aaddc5b6ef7e 100644 --- a/packages/angular/build/src/builders/application/execute-build.ts +++ b/packages/angular/build/src/builders/application/execute-build.ts @@ -56,6 +56,7 @@ export async function executeBuild( verbose, colors, jsonLogs, + security, } = options; // TODO: Consider integrating into watch mode. Would require full rebuild on target changes. @@ -263,7 +264,7 @@ export async function executeBuild( if (serverEntryPoint) { executionResult.addOutputFile( SERVER_APP_ENGINE_MANIFEST_FILENAME, - generateAngularServerAppEngineManifest(i18nOptions, baseHref), + generateAngularServerAppEngineManifest(i18nOptions, security.allowedHosts, baseHref), BuildOutputFileType.ServerRoot, ); } diff --git a/packages/angular/build/src/builders/application/options.ts b/packages/angular/build/src/builders/application/options.ts index 83b7ea428f35..4f0d1295a7e3 100644 --- a/packages/angular/build/src/builders/application/options.ts +++ b/packages/angular/build/src/builders/application/options.ts @@ -400,8 +400,9 @@ export async function normalizeOptions( } } - const autoCsp = options.security?.autoCsp; + const { autoCsp, allowedHosts = [] } = options.security ?? {}; const security = { + allowedHosts, autoCsp: autoCsp ? { unsafeEval: autoCsp === true ? false : !!autoCsp.unsafeEval, diff --git a/packages/angular/build/src/builders/application/schema.json b/packages/angular/build/src/builders/application/schema.json index 8db4e6145b3f..77e844ecb6e1 100644 --- a/packages/angular/build/src/builders/application/schema.json +++ b/packages/angular/build/src/builders/application/schema.json @@ -52,6 +52,14 @@ "type": "object", "additionalProperties": false, "properties": { + "allowedHosts": { + "description": "A list of hosts that are allowed to access the server-side application.", + "type": "array", + "uniqueItems": true, + "items": { + "type": "string" + } + }, "autoCsp": { "description": "Enables automatic generation of a hash-based Strict Content Security Policy (https://web.dev/articles/strict-csp#choose-hash) based on scripts in index.html. Will default to true once we are out of experimental/preview phases.", "default": false, diff --git a/packages/angular/build/src/builders/dev-server/vite/index.ts b/packages/angular/build/src/builders/dev-server/vite/index.ts index 8129daac1ba1..1916bcc85d1b 100644 --- a/packages/angular/build/src/builders/dev-server/vite/index.ts +++ b/packages/angular/build/src/builders/dev-server/vite/index.ts @@ -9,7 +9,6 @@ import type { BuilderContext } from '@angular-devkit/architect'; import type { Plugin } from 'esbuild'; import assert from 'node:assert'; -import { builtinModules, isBuiltin } from 'node:module'; import { join } from 'node:path'; import type { Connect, ViteDevServer } from 'vite'; import type { ComponentStyleRecord } from '../../../tools/vite/middlewares'; @@ -21,7 +20,6 @@ import { Result, ResultKind } from '../../application/results'; import { OutputHashing } from '../../application/schema'; import { type ApplicationBuilderInternalOptions, - type ExternalResultMetadata, JavaScriptTransformer, getSupportedBrowsers, isZonelessApp, @@ -102,6 +100,7 @@ export async function* serveWithVite( // Disable auto CSP. browserOptions.security = { autoCsp: false, + allowedHosts: Array.isArray(serverOptions.allowedHosts) ? serverOptions.allowedHosts : [], }; // Disable JSON build stats. diff --git a/packages/angular/build/src/utils/server-rendering/manifest.ts b/packages/angular/build/src/utils/server-rendering/manifest.ts index b01bff38b58f..1b52227f3049 100644 --- a/packages/angular/build/src/utils/server-rendering/manifest.ts +++ b/packages/angular/build/src/utils/server-rendering/manifest.ts @@ -53,11 +53,13 @@ function escapeUnsafeChars(str: string): string { * * @param i18nOptions - The internationalization options for the application build. This * includes settings for inlining locales and determining the output structure. + * @param allowedHosts - A list of hosts that are allowed to access the server-side application. * @param baseHref - The base HREF for the application. This is used to set the base URL * for all relative URLs in the application. */ export function generateAngularServerAppEngineManifest( i18nOptions: NormalizedApplicationBuildOptions['i18nOptions'], + allowedHosts: string[], baseHref: string | undefined, ): string { const entryPoints: Record = {}; @@ -84,6 +86,11 @@ export function generateAngularServerAppEngineManifest( const manifestContent = ` export default { basePath: '${basePath}', + allowedHosts: ${JSON.stringify( + allowedHosts.map((host) => host.replace(/^www\./i, '')), + undefined, + 2, + )}, supportedLocales: ${JSON.stringify(supportedLocales, undefined, 2)}, entryPoints: { ${Object.entries(entryPoints) diff --git a/packages/angular/ssr/node/src/common-engine/common-engine.ts b/packages/angular/ssr/node/src/common-engine/common-engine.ts index 673156ee6b43..3693f9e9c913 100644 --- a/packages/angular/ssr/node/src/common-engine/common-engine.ts +++ b/packages/angular/ssr/node/src/common-engine/common-engine.ts @@ -20,6 +20,11 @@ import { runMethodAndMeasurePerf, } from './peformance-profiler'; +/** + * Regular expression to match and remove the `www.` prefix from hostnames. + */ +const WWW_HOST_REGEX = /^www\./i; + const SSG_MARKER_REGEXP = /ng-server-context=["']\w*\|?ssg\|?\w*["']/; export interface CommonEngineOptions { @@ -31,6 +36,9 @@ export interface CommonEngineOptions { /** Enable request performance profiling data collection and printing the results in the server console. */ enablePerformanceProfiler?: boolean; + + /** A set of hostnames that are allowed to access the server. */ + allowedHosts: readonly string[]; } export interface CommonEngineRenderOptions { @@ -64,8 +72,17 @@ export class CommonEngine { private readonly templateCache = new Map(); private readonly inlineCriticalCssProcessor = new CommonEngineInlineCriticalCssProcessor(); private readonly pageIsSSG = new Map(); + private readonly allowedHosts: ReadonlySet; + + constructor(private options: CommonEngineOptions) { + this.allowedHosts = new Set([ + ...options.allowedHosts.map((host) => host.replace(WWW_HOST_REGEX, '')), + 'localhost', + '127.0.0.1', + '::1', + '[::1]', + ]); - constructor(private options?: CommonEngineOptions | undefined) { attachNodeGlobalErrorHandlers(); } @@ -74,6 +91,10 @@ export class CommonEngine { * render options */ async render(opts: CommonEngineRenderOptions): Promise { + if (opts.url) { + this.validateHost(opts.url); + } + const enablePerformanceProfiler = this.options?.enablePerformanceProfiler; const runMethod = enablePerformanceProfiler @@ -102,6 +123,34 @@ export class CommonEngine { return html; } + private validateHost(url: string): void { + if (!URL.canParse(url)) { + throw new Error(`URL "${url}" is invalid.`); + } + + const hostname = new URL(url).hostname.replace(WWW_HOST_REGEX, ''); + + if (this.allowedHosts.has(hostname)) { + return; + } + + // Support wildcard hostnames. + for (const allowedHost of this.allowedHosts) { + if (!allowedHost.startsWith('*.')) { + continue; + } + + const domain = allowedHost.slice(1); + if (hostname.endsWith(domain)) { + return; + } + } + + throw new Error( + `Host ${hostname} is not allowed. Please provide a list of allowed hosts in the "allowedHosts" option.`, + ); + } + private inlineCriticalCss(html: string, opts: CommonEngineRenderOptions): Promise { const outputPath = opts.publicPath ?? (opts.documentFilePath ? dirname(opts.documentFilePath) : ''); diff --git a/packages/angular/ssr/node/src/request.ts b/packages/angular/ssr/node/src/request.ts index 32d90d0029fc..451987739809 100644 --- a/packages/angular/ssr/node/src/request.ts +++ b/packages/angular/ssr/node/src/request.ts @@ -8,6 +8,7 @@ import type { IncomingHttpHeaders, IncomingMessage } from 'node:http'; import type { Http2ServerRequest } from 'node:http2'; +import { getFirstHeaderValue } from '../../src/utils/headers'; /** * A set containing all the pseudo-headers defined in the HTTP/2 specification. @@ -103,21 +104,3 @@ export function createRequestUrl(nodeRequest: IncomingMessage | Http2ServerReque return new URL(`${protocol}://${hostnameWithPort}${originalUrl ?? url}`); } - -/** - * Extracts the first value from a multi-value header string. - * - * @param value - A string or an array of strings representing the header values. - * If it's a string, values are expected to be comma-separated. - * @returns The first trimmed value from the multi-value header, or `undefined` if the input is invalid or empty. - * - * @example - * ```typescript - * getFirstHeaderValue("value1, value2, value3"); // "value1" - * getFirstHeaderValue(["value1", "value2"]); // "value1" - * getFirstHeaderValue(undefined); // undefined - * ``` - */ -function getFirstHeaderValue(value: string | string[] | undefined): string | undefined { - return value?.toString().split(',', 1)[0]?.trim(); -} diff --git a/packages/angular/ssr/src/app-engine.ts b/packages/angular/ssr/src/app-engine.ts index 0cb728e8535d..9b4e206836e9 100644 --- a/packages/angular/ssr/src/app-engine.ts +++ b/packages/angular/ssr/src/app-engine.ts @@ -10,6 +10,7 @@ import type { AngularServerApp, getOrCreateAngularServerApp } from './app'; import { Hooks } from './hooks'; import { getPotentialLocaleIdFromUrl, getPreferredLocale } from './i18n'; import { EntryPointExports, getAngularAppEngineManifest } from './manifest'; +import { validateHeaders } from './utils/headers'; import { joinUrlParts } from './utils/url'; /** @@ -45,6 +46,11 @@ export class AngularAppEngine { */ private readonly manifest = getAngularAppEngineManifest(); + /** + * A set of allowed hostnames for the server application. + */ + private readonly allowedHosts: ReadonlySet = new Set(this.manifest.allowedHosts); + /** * A map of supported locales from the server application's manifest. */ @@ -67,10 +73,25 @@ export class AngularAppEngine { * * @remarks A request to `https://www.example.com/page/index.html` will serve or render the Angular route * corresponding to `https://www.example.com/page`. + * + * @remarks If the `Host` or `X-Forwarded-Host` header value is not in the allowed hosts list, this function will return a 400 response. + * To resolve this, configure the `allowedHosts` option in `angular.json` and include the hostname. + * Path: `projects.[project-name].architect.build.options.security.allowedHosts`. */ async handle(request: Request, requestContext?: unknown): Promise { - const serverApp = await this.getAngularServerAppForRequest(request); + try { + validateHeaders(request, this.allowedHosts); + } catch (error) { + const body = error instanceof Error ? error.message : undefined; + + return new Response(body, { + status: 400, + statusText: 'Bad Request', + headers: { 'Content-Type': 'text/plain' }, + }); + } + const serverApp = await this.getAngularServerAppForRequest(request); if (serverApp) { return serverApp.handle(request, requestContext); } diff --git a/packages/angular/ssr/src/manifest.ts b/packages/angular/ssr/src/manifest.ts index 0de603bba104..21ded49b3e10 100644 --- a/packages/angular/ssr/src/manifest.ts +++ b/packages/angular/ssr/src/manifest.ts @@ -6,7 +6,6 @@ * found in the LICENSE file at https://angular.dev/license */ -import type { BootstrapContext } from '@angular/platform-browser'; import type { SerializableRouteTreeNode } from './routes/route-tree'; import { AngularBootstrap } from './utils/ng'; @@ -74,6 +73,11 @@ export interface AngularAppEngineManifest { * - `value`: The url segment associated with that locale. */ readonly supportedLocales: Readonly>; + + /** + * A readonly array of allowed hostnames. + */ + readonly allowedHosts: Readonly; } /** diff --git a/packages/angular/ssr/src/utils/headers.ts b/packages/angular/ssr/src/utils/headers.ts new file mode 100644 index 000000000000..24636f00cfc3 --- /dev/null +++ b/packages/angular/ssr/src/utils/headers.ts @@ -0,0 +1,156 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +/** + * Regular expression to validate that the port is a numeric value. + */ +const VALID_PORT_REGEX = /^\d+$/; + +/** + * Regular expression to validate that the protocol is either http or https (case-insensitive). + */ +const VALID_PROTO_REGEX = /^https?$/i; + +/** + * Regular expression to match and remove the `www.` prefix from hostnames. + */ +const WWW_HOST_REGEX = /^www\./i; + +/** + * Regular expression to match path separators. + */ +const PATH_SEPARATOR_REGEX = /[/\\]/; + +/** + * Set of hostnames that are always allowed. + */ +const DEFAULT_ALLOWED_HOSTS: ReadonlySet = new Set([ + '*.localhost', + 'localhost', + '127.0.0.1', + '::1', + '[::1]', +]); + +/** + * Extracts the first value from a multi-value header string. + * + * @param value - A string or an array of strings representing the header values. + * If it's a string, values are expected to be comma-separated. + * @returns The first trimmed value from the multi-value header, or `undefined` if the input is invalid or empty. + * + * @example + * ```typescript + * getFirstHeaderValue("value1, value2, value3"); // "value1" + * getFirstHeaderValue(["value1", "value2"]); // "value1" + * getFirstHeaderValue(undefined); // undefined + * ``` + */ +export function getFirstHeaderValue( + value: string | string[] | undefined | null, +): string | undefined { + return value?.toString().split(',', 1)[0]?.trim(); +} + +/** + * Validates the headers of an incoming request. + * + * This function checks for the validity of critical headers such as `x-forwarded-host`, + * `host`, `x-forwarded-port`, and `x-forwarded-proto`. + * It ensures that the hostnames match the allowed hosts and that ports and protocols adhere to expected formats. + * + * @param request - The incoming `Request` object containing the headers to validate. + * @param allowedHosts - A set of allowed hostnames. + * @throws Error if any of the validated headers contain invalid values. + */ +export function validateHeaders(request: Request, allowedHosts: ReadonlySet): void { + const headers = request.headers; + validateHost('x-forwarded-host', headers, allowedHosts); + validateHost('host', headers, allowedHosts); + + const xForwardedPort = getFirstHeaderValue(headers.get('x-forwarded-port')); + if (xForwardedPort && !VALID_PORT_REGEX.test(xForwardedPort)) { + throw new Error('Header "x-forwarded-port" must be a numeric value.'); + } + + const xForwardedProto = getFirstHeaderValue(headers.get('x-forwarded-proto')); + if (xForwardedProto && !VALID_PROTO_REGEX.test(xForwardedProto)) { + throw new Error('Header "x-forwarded-proto" must be either "http" or "https".'); + } +} + +/** + * Validates a specific host header value against the allowed hosts. + * + * @param headerName - The name of the header to validate (e.g., 'host', 'x-forwarded-host'). + * @param headers - The `Headers` object from the request. + * @param allowedHosts - A set of allowed hostnames. + * @throws Error if the header value is invalid or the hostname is not in the allowlist. + */ +function validateHost( + headerName: string, + headers: Headers, + allowedHosts: ReadonlySet, +): void { + const value = getFirstHeaderValue(headers.get(headerName))?.replace(WWW_HOST_REGEX, ''); + if (!value) { + return; + } + + // Reject any hostname containing path separators - they're invalid. + if (PATH_SEPARATOR_REGEX.test(value)) { + throw new Error(`Header "${headerName}" contains path separators which is not allowed.`); + } + + const url = `http://${value}`; + if (!URL.canParse(url)) { + throw new Error(`Header "${headerName}" contains an invalid value.`); + } + + const { hostname } = new URL(url); + if ( + // Check the provided allowed hosts first. + allowedHosts.has(hostname) || + checkWildcardHostnames(hostname, allowedHosts) || + // Check the default allowed hosts last this is the fallback and should be rarely if ever used in production. + DEFAULT_ALLOWED_HOSTS.has(hostname) || + checkWildcardHostnames(hostname, DEFAULT_ALLOWED_HOSTS) + ) { + return; + } + + let errorMessage = `Header "${headerName}" with value "${value}" is not allowed.`; + if (typeof ngDevMode === 'undefined' || ngDevMode) { + errorMessage += + '\n\nAction Required: Update your "angular.json" to include this hostname. ' + + 'Path: "projects.[project-name].architect.build.options.security.allowedHosts".'; + } + + throw new Error(errorMessage); +} + +/** + * Checks if the hostname matches any of the wildcard hostnames in the allowlist. + * @param hostname - The hostname to check. + * @param allowedHosts - A set of allowed hostnames. + * @returns `true` if the hostname matches any of the wildcard hostnames, `false` otherwise. + */ +function checkWildcardHostnames(hostname: string, allowedHosts: ReadonlySet): boolean { + for (const allowedHost of allowedHosts) { + if (!allowedHost.startsWith('*.')) { + continue; + } + + const domain = allowedHost.slice(1); + if (hostname.endsWith(domain)) { + return true; + } + } + + return false; +} diff --git a/packages/angular/ssr/test/app-engine_spec.ts b/packages/angular/ssr/test/app-engine_spec.ts index b08931b9400b..f1f34a75c0ad 100644 --- a/packages/angular/ssr/test/app-engine_spec.ts +++ b/packages/angular/ssr/test/app-engine_spec.ts @@ -81,6 +81,7 @@ describe('AngularAppEngine', () => { describe('Localized app', () => { beforeAll(() => { setAngularAppEngineManifest({ + allowedHosts: ['example.com'], // Note: Although we are testing only one locale, we need to configure two or more // to ensure that we test a different code path. entryPoints: { @@ -160,6 +161,7 @@ describe('AngularAppEngine', () => { describe('Localized app with single locale', () => { beforeAll(() => { setAngularAppEngineManifest({ + allowedHosts: ['example.com'], entryPoints: { it: createEntryPoint('it'), }, @@ -226,6 +228,7 @@ describe('AngularAppEngine', () => { class HomeComponent {} setAngularAppEngineManifest({ + allowedHosts: ['example.com'], entryPoints: { '': async () => { setAngularAppTestingManifest( @@ -270,4 +273,57 @@ describe('AngularAppEngine', () => { expect(await response?.text()).toContain('Home works'); }); }); + + describe('Invalid host headers', () => { + beforeAll(() => { + setAngularAppEngineManifest({ + allowedHosts: ['example.com'], + entryPoints: {}, + basePath: '/', + supportedLocales: { 'en-US': '' }, + }); + + appEngine = new AngularAppEngine(); + }); + + it('should return 400 for disallowed host', async () => { + const request = new Request('https://example.com', { + headers: { + 'host': 'evil.com', + }, + }); + + const response = await appEngine.handle(request); + expect(response?.status).toBe(400); + expect(await response?.text()).toContain( + 'Header "host" with value "evil.com" is not allowed.', + ); + }); + + it('should return 400 for disallowed x-forwarded-host', async () => { + const request = new Request('https://example.com', { + headers: { + 'x-forwarded-host': 'evil.com', + }, + }); + const response = await appEngine.handle(request); + expect(response?.status).toBe(400); + expect(await response?.text()).toContain( + 'Header "x-forwarded-host" with value "evil.com" is not allowed.', + ); + }); + + it('should return 400 for host with path separator', async () => { + const request = new Request('https://example.com', { + headers: { + 'host': 'example.com/evil', + }, + }); + const response = await appEngine.handle(request); + expect(response?.status).toBe(400); + expect(await response?.text()).toContain( + 'Header "host" contains path separators which is not allowed.', + ); + }); + }); }); diff --git a/packages/angular/ssr/test/utils/headers_spec.ts b/packages/angular/ssr/test/utils/headers_spec.ts new file mode 100644 index 000000000000..ab539de4b7dc --- /dev/null +++ b/packages/angular/ssr/test/utils/headers_spec.ts @@ -0,0 +1,181 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { validateHeaders } from '../../src/utils/headers'; + +describe('validateHeaders', () => { + const allowedHosts = new Set(['example.com', 'sub.example.com']); + + it('should pass valid headers with allowed host', () => { + const request = new Request('https://example.com', { + headers: { + 'host': 'example.com', + 'x-forwarded-host': 'sub.example.com', + 'x-forwarded-port': '443', + 'x-forwarded-proto': 'https', + }, + }); + + expect(() => validateHeaders(request, allowedHosts)).not.toThrow(); + }); + + it('should pass valid headers with localhost (default allowed)', () => { + const request = new Request('https://localhost', { + headers: { + 'host': 'localhost', + }, + }); + + expect(() => validateHeaders(request, allowedHosts)).not.toThrow(); + }); + + it('should throw error for disallowed host', () => { + const request = new Request('https://evil.com', { + headers: { + 'host': 'evil.com', + }, + }); + + expect(() => validateHeaders(request, allowedHosts)).toThrowError( + /Header "host" with value "evil\.com" is not allowed/, + ); + }); + + it('should throw error for disallowed x-forwarded-host', () => { + const request = new Request('https://example.com', { + headers: { + 'host': 'example.com', + 'x-forwarded-host': 'evil.com', + }, + }); + + expect(() => validateHeaders(request, allowedHosts)).toThrowError( + /Header "x-forwarded-host" with value "evil\.com" is not allowed/, + ); + }); + + it('should throw error for invalid x-forwarded-host containing path separators', () => { + const request = new Request('https://example.com', { + headers: { + 'host': 'example.com', + 'x-forwarded-host': 'example.com/evil', + }, + }); + + expect(() => validateHeaders(request, allowedHosts)).toThrowError( + 'Header "x-forwarded-host" contains path separators which is not allowed.', + ); + }); + + it('should throw error for invalid x-forwarded-port (non-numeric)', () => { + const request = new Request('https://example.com', { + headers: { + 'host': 'example.com', + 'x-forwarded-port': 'abc', + }, + }); + + expect(() => validateHeaders(request, allowedHosts)).toThrowError( + 'Header "x-forwarded-port" must be a numeric value.', + ); + }); + + it('should throw error for invalid x-forwarded-proto', () => { + const request = new Request('https://example.com', { + headers: { + 'host': 'example.com', + 'x-forwarded-proto': 'ftp', + }, + }); + + expect(() => validateHeaders(request, allowedHosts)).toThrowError( + 'Header "x-forwarded-proto" must be either "http" or "https".', + ); + }); + + it('should pass for valid x-forwarded-proto (case insensitive)', () => { + const request = new Request('https://example.com', { + headers: { + 'host': 'example.com', + 'x-forwarded-proto': 'HTTP', + }, + }); + + expect(() => validateHeaders(request, allowedHosts)).not.toThrow(); + }); + + it('should ignore port in host validation', () => { + const request = new Request('https://example.com:8080', { + headers: { + 'host': 'example.com:8080', + }, + }); + + expect(() => validateHeaders(request, allowedHosts)).not.toThrow(); + }); + + it('should throw if host header is completely malformed url', () => { + const request = new Request('https://example.com', { + headers: { + 'host': '[', + }, + }); + + expect(() => validateHeaders(request, allowedHosts)).toThrowError( + 'Header "host" contains an invalid value.', + ); + }); + + describe('wildcard allowed hosts', () => { + const wildcardHosts = new Set(['*.example.com']); + + it('should match subdomain', () => { + const request = new Request('https://sub.example.com', { + headers: { + 'host': 'sub.example.com', + }, + }); + + expect(() => validateHeaders(request, wildcardHosts)).not.toThrow(); + }); + + it('should match nested subdomain', () => { + const request = new Request('https://deep.sub.example.com', { + headers: { + 'host': 'deep.sub.example.com', + }, + }); + + expect(() => validateHeaders(request, wildcardHosts)).not.toThrow(); + }); + + it('should not match base domain', () => { + const request = new Request('https://example.com', { + headers: { + 'host': 'example.com', + }, + }); + + expect(() => validateHeaders(request, wildcardHosts)).toThrowError( + /Header "host" with value "example\.com" is not allowed/, + ); + }); + + it('should not match other domain', () => { + const request = new Request('https://evil.com', { + headers: { + 'host': 'evil.com', + }, + }); + + expect(() => validateHeaders(request, wildcardHosts)).toThrowError( + /Header "host" with value "evil\.com" is not allowed/, + ); + }); + }); +}); diff --git a/packages/angular_devkit/build_angular/src/builders/ssr-dev-server/specs/proxy_spec.ts b/packages/angular_devkit/build_angular/src/builders/ssr-dev-server/specs/proxy_spec.ts index cbde961e59e4..d9be695ee46c 100644 --- a/packages/angular_devkit/build_angular/src/builders/ssr-dev-server/specs/proxy_spec.ts +++ b/packages/angular_devkit/build_angular/src/builders/ssr-dev-server/specs/proxy_spec.ts @@ -41,7 +41,7 @@ describe('Serve SSR Builder', () => { const server = express(); const distFolder = resolve(__dirname, '../dist'); const indexHtml = join(distFolder, 'index.html'); - const commonEngine = new CommonEngine(); + const commonEngine = new CommonEngine({ allowedHosts: [] }); server.set('view engine', 'html'); server.set('views', distFolder); @@ -52,11 +52,12 @@ describe('Serve SSR Builder', () => { })); server.use((req, res, next) => { + const { protocol, originalUrl, baseUrl, headers } = req; commonEngine .render({ bootstrap: AppServerModule, documentFilePath: indexHtml, - url: req.originalUrl, + url: \`\${protocol}://\${headers.host}\${originalUrl}\`, publicPath: distFolder, }) .then((html) => res.send(html)) diff --git a/packages/angular_devkit/build_angular/src/builders/ssr-dev-server/specs/ssl_spec.ts b/packages/angular_devkit/build_angular/src/builders/ssr-dev-server/specs/ssl_spec.ts index 7651b2387c16..e13b398e352a 100644 --- a/packages/angular_devkit/build_angular/src/builders/ssr-dev-server/specs/ssl_spec.ts +++ b/packages/angular_devkit/build_angular/src/builders/ssr-dev-server/specs/ssl_spec.ts @@ -41,7 +41,7 @@ describe('Serve SSR Builder', () => { const server = express(); const distFolder = resolve(__dirname, '../dist'); const indexHtml = join(distFolder, 'index.html'); - const commonEngine = new CommonEngine(); + const commonEngine = new CommonEngine({ allowedHosts: [] }); server.set('view engine', 'html'); server.set('views', distFolder); @@ -52,11 +52,12 @@ describe('Serve SSR Builder', () => { })); server.use((req, res, next) => { + const { protocol, originalUrl, baseUrl, headers } = req; commonEngine .render({ bootstrap: AppServerModule, documentFilePath: indexHtml, - url: req.originalUrl, + url: \`\${protocol}://\${headers.host}\${originalUrl}\`, publicPath: distFolder, }) .then((html) => res.send(html)) diff --git a/packages/angular_devkit/build_angular/src/builders/ssr-dev-server/specs/works_spec.ts b/packages/angular_devkit/build_angular/src/builders/ssr-dev-server/specs/works_spec.ts index 64c56024f089..9caf727aea02 100644 --- a/packages/angular_devkit/build_angular/src/builders/ssr-dev-server/specs/works_spec.ts +++ b/packages/angular_devkit/build_angular/src/builders/ssr-dev-server/specs/works_spec.ts @@ -40,7 +40,7 @@ describe('Serve SSR Builder', () => { const server = express(); const distFolder = resolve(__dirname, '../dist'); const indexHtml = join(distFolder, 'index.html'); - const commonEngine = new CommonEngine(); + const commonEngine = new CommonEngine({ allowedHosts: [] }); server.set('view engine', 'html'); server.set('views', distFolder); @@ -51,11 +51,12 @@ describe('Serve SSR Builder', () => { })); server.use((req, res, next) => { + const { protocol, originalUrl, baseUrl, headers } = req; commonEngine .render({ bootstrap: AppServerModule, documentFilePath: indexHtml, - url: req.originalUrl, + url: \`\${protocol}://\${headers.host}\${originalUrl}\`, publicPath: distFolder, }) .then((html) => res.send(html)) diff --git a/packages/schematics/angular/ssr/files/server-builder/server.ts.template b/packages/schematics/angular/ssr/files/server-builder/server.ts.template index 7327c26532ea..956ac56eaa35 100644 --- a/packages/schematics/angular/ssr/files/server-builder/server.ts.template +++ b/packages/schematics/angular/ssr/files/server-builder/server.ts.template @@ -13,7 +13,9 @@ export function app(): express.Express { ? join(distFolder, 'index.original.html') : join(distFolder, 'index.html'); - const commonEngine = new CommonEngine(); + const commonEngine = new CommonEngine({ + allowedHosts: [/* Provide a list of allowed hosts. */], + }); server.set('view engine', 'html'); server.set('views', distFolder); diff --git a/packages/schematics/angular/ssr/index.ts b/packages/schematics/angular/ssr/index.ts index 6e27eab47cd5..49e57d523268 100644 --- a/packages/schematics/angular/ssr/index.ts +++ b/packages/schematics/angular/ssr/index.ts @@ -6,7 +6,7 @@ * found in the LICENSE file at https://angular.dev/license */ -import { isJsonObject } from '@angular-devkit/core'; +import { JsonObject, isJsonObject } from '@angular-devkit/core'; import { Rule, RuleFactory, @@ -206,6 +206,10 @@ function updateApplicationBuilderWorkspaceConfigRule( buildTarget.options = { ...buildTarget.options, + security: { + ...((buildTarget.options?.security as JsonObject | undefined) ?? {}), + allowedHosts: [], + }, outputPath, outputMode: 'server', ssr: { diff --git a/tests/e2e/assets/ssr-project-webpack/server.ts b/tests/e2e/assets/ssr-project-webpack/server.ts index 59f788024bb6..9278439d9a66 100644 --- a/tests/e2e/assets/ssr-project-webpack/server.ts +++ b/tests/e2e/assets/ssr-project-webpack/server.ts @@ -15,7 +15,7 @@ export function app(): express.Express { ? join(distFolder, 'index.original.html') : join(distFolder, 'index.html'); - const commonEngine = new CommonEngine(); + const commonEngine = new CommonEngine({ allowedHosts: [] }); server.set('view engine', 'html'); server.set('views', distFolder); diff --git a/tests/e2e/utils/project.ts b/tests/e2e/utils/project.ts index 0deb6ea48262..a8c6e49b6d07 100644 --- a/tests/e2e/utils/project.ts +++ b/tests/e2e/utils/project.ts @@ -200,7 +200,7 @@ export function updateServerFileForEsbuild(filepath: string): Promise { const browserDistFolder = resolve(serverDistFolder, '../browser'); const indexHtml = join(serverDistFolder, 'index.server.html'); - const commonEngine = new CommonEngine(); + const commonEngine = new CommonEngine({ allowedHosts: [] }); server.set('view engine', 'html'); server.set('views', browserDistFolder); From 60e7b8162cbdfdf4b50d5764fa46bb0189a6493c Mon Sep 17 00:00:00 2001 From: Alan Agius <17563226+alan-agius4@users.noreply.github.com> Date: Wed, 18 Feb 2026 13:05:28 +0000 Subject: [PATCH 2/2] fixup! fix(@angular/ssr): validate host headers to prevent header-based SSRF --- .../src/builders/application/schema.json | 2 +- .../src/utils/server-rendering/manifest.ts | 6 +- .../node/src/common-engine/common-engine.ts | 39 ++---- packages/angular/ssr/node/src/request.ts | 2 +- packages/angular/ssr/src/app-engine.ts | 4 +- .../src/utils/{headers.ts => validation.ts} | 112 ++++++++++++------ .../{headers_spec.ts => validation_spec.ts} | 51 +++++--- 7 files changed, 122 insertions(+), 94 deletions(-) rename packages/angular/ssr/src/utils/{headers.ts => validation.ts} (64%) rename packages/angular/ssr/test/utils/{headers_spec.ts => validation_spec.ts} (71%) diff --git a/packages/angular/build/src/builders/application/schema.json b/packages/angular/build/src/builders/application/schema.json index 77e844ecb6e1..95c13ec97841 100644 --- a/packages/angular/build/src/builders/application/schema.json +++ b/packages/angular/build/src/builders/application/schema.json @@ -53,7 +53,7 @@ "additionalProperties": false, "properties": { "allowedHosts": { - "description": "A list of hosts that are allowed to access the server-side application.", + "description": "A list of hostnames that are allowed to access the server-side application. For more information, see https://angular.dev/guide/ssr#configuring-allowed-hosts.", "type": "array", "uniqueItems": true, "items": { diff --git a/packages/angular/build/src/utils/server-rendering/manifest.ts b/packages/angular/build/src/utils/server-rendering/manifest.ts index 1b52227f3049..34c2e334b52c 100644 --- a/packages/angular/build/src/utils/server-rendering/manifest.ts +++ b/packages/angular/build/src/utils/server-rendering/manifest.ts @@ -86,11 +86,7 @@ export function generateAngularServerAppEngineManifest( const manifestContent = ` export default { basePath: '${basePath}', - allowedHosts: ${JSON.stringify( - allowedHosts.map((host) => host.replace(/^www\./i, '')), - undefined, - 2, - )}, + allowedHosts: ${JSON.stringify(allowedHosts, undefined, 2)}, supportedLocales: ${JSON.stringify(supportedLocales, undefined, 2)}, entryPoints: { ${Object.entries(entryPoints) diff --git a/packages/angular/ssr/node/src/common-engine/common-engine.ts b/packages/angular/ssr/node/src/common-engine/common-engine.ts index 3693f9e9c913..dedbc7d04a09 100644 --- a/packages/angular/ssr/node/src/common-engine/common-engine.ts +++ b/packages/angular/ssr/node/src/common-engine/common-engine.ts @@ -12,6 +12,7 @@ import { renderApplication, renderModule, ɵSERVER_CONTEXT } from '@angular/plat import * as fs from 'node:fs'; import { dirname, join, normalize, resolve } from 'node:path'; import { URL } from 'node:url'; +import { isHostAllowed } from '../../../src/utils/validation'; import { attachNodeGlobalErrorHandlers } from '../errors'; import { CommonEngineInlineCriticalCssProcessor } from './inline-css-processor'; import { @@ -20,11 +21,6 @@ import { runMethodAndMeasurePerf, } from './peformance-profiler'; -/** - * Regular expression to match and remove the `www.` prefix from hostnames. - */ -const WWW_HOST_REGEX = /^www\./i; - const SSG_MARKER_REGEXP = /ng-server-context=["']\w*\|?ssg\|?\w*["']/; export interface CommonEngineOptions { @@ -75,13 +71,7 @@ export class CommonEngine { private readonly allowedHosts: ReadonlySet; constructor(private options: CommonEngineOptions) { - this.allowedHosts = new Set([ - ...options.allowedHosts.map((host) => host.replace(WWW_HOST_REGEX, '')), - 'localhost', - '127.0.0.1', - '::1', - '[::1]', - ]); + this.allowedHosts = new Set(options.allowedHosts); attachNodeGlobalErrorHandlers(); } @@ -128,27 +118,12 @@ export class CommonEngine { throw new Error(`URL "${url}" is invalid.`); } - const hostname = new URL(url).hostname.replace(WWW_HOST_REGEX, ''); - - if (this.allowedHosts.has(hostname)) { - return; - } - - // Support wildcard hostnames. - for (const allowedHost of this.allowedHosts) { - if (!allowedHost.startsWith('*.')) { - continue; - } - - const domain = allowedHost.slice(1); - if (hostname.endsWith(domain)) { - return; - } + const hostname = new URL(url).hostname; + if (!isHostAllowed(hostname, this.allowedHosts)) { + throw new Error( + `Host ${hostname} is not allowed. Please provide a list of allowed hosts in the "allowedHosts" option.`, + ); } - - throw new Error( - `Host ${hostname} is not allowed. Please provide a list of allowed hosts in the "allowedHosts" option.`, - ); } private inlineCriticalCss(html: string, opts: CommonEngineRenderOptions): Promise { diff --git a/packages/angular/ssr/node/src/request.ts b/packages/angular/ssr/node/src/request.ts index 451987739809..402ec29ba56d 100644 --- a/packages/angular/ssr/node/src/request.ts +++ b/packages/angular/ssr/node/src/request.ts @@ -8,7 +8,7 @@ import type { IncomingHttpHeaders, IncomingMessage } from 'node:http'; import type { Http2ServerRequest } from 'node:http2'; -import { getFirstHeaderValue } from '../../src/utils/headers'; +import { getFirstHeaderValue } from '../../src/utils/validation'; /** * A set containing all the pseudo-headers defined in the HTTP/2 specification. diff --git a/packages/angular/ssr/src/app-engine.ts b/packages/angular/ssr/src/app-engine.ts index 9b4e206836e9..80bed8554065 100644 --- a/packages/angular/ssr/src/app-engine.ts +++ b/packages/angular/ssr/src/app-engine.ts @@ -10,8 +10,8 @@ import type { AngularServerApp, getOrCreateAngularServerApp } from './app'; import { Hooks } from './hooks'; import { getPotentialLocaleIdFromUrl, getPreferredLocale } from './i18n'; import { EntryPointExports, getAngularAppEngineManifest } from './manifest'; -import { validateHeaders } from './utils/headers'; import { joinUrlParts } from './utils/url'; +import { validateRequest } from './utils/validation'; /** * Angular server application engine. @@ -80,7 +80,7 @@ export class AngularAppEngine { */ async handle(request: Request, requestContext?: unknown): Promise { try { - validateHeaders(request, this.allowedHosts); + validateRequest(request, this.allowedHosts); } catch (error) { const body = error instanceof Error ? error.message : undefined; diff --git a/packages/angular/ssr/src/utils/headers.ts b/packages/angular/ssr/src/utils/validation.ts similarity index 64% rename from packages/angular/ssr/src/utils/headers.ts rename to packages/angular/ssr/src/utils/validation.ts index 24636f00cfc3..355f03ca62d0 100644 --- a/packages/angular/ssr/src/utils/headers.ts +++ b/packages/angular/ssr/src/utils/validation.ts @@ -16,11 +16,6 @@ const VALID_PORT_REGEX = /^\d+$/; */ const VALID_PROTO_REGEX = /^https?$/i; -/** - * Regular expression to match and remove the `www.` prefix from hostnames. - */ -const WWW_HOST_REGEX = /^www\./i; - /** * Regular expression to match path separators. */ @@ -58,29 +53,36 @@ export function getFirstHeaderValue( } /** - * Validates the headers of an incoming request. - * - * This function checks for the validity of critical headers such as `x-forwarded-host`, - * `host`, `x-forwarded-port`, and `x-forwarded-proto`. - * It ensures that the hostnames match the allowed hosts and that ports and protocols adhere to expected formats. + * Validates a request. * - * @param request - The incoming `Request` object containing the headers to validate. + * @param request - The incoming `Request` object to validate. * @param allowedHosts - A set of allowed hostnames. * @throws Error if any of the validated headers contain invalid values. */ -export function validateHeaders(request: Request, allowedHosts: ReadonlySet): void { - const headers = request.headers; - validateHost('x-forwarded-host', headers, allowedHosts); - validateHost('host', headers, allowedHosts); +export function validateRequest(request: Request, allowedHosts: ReadonlySet): void { + validateHeaders(request, allowedHosts); + validateUrl(new URL(request.url), allowedHosts); +} - const xForwardedPort = getFirstHeaderValue(headers.get('x-forwarded-port')); - if (xForwardedPort && !VALID_PORT_REGEX.test(xForwardedPort)) { - throw new Error('Header "x-forwarded-port" must be a numeric value.'); - } +/** + * Validates that the hostname of a given URL is allowed. + * + * @param url - The URL object to validate. + * @param allowedHosts - A set of allowed hostnames. + * @throws Error if the hostname is not in the allowlist. + */ +export function validateUrl(url: URL, allowedHosts: ReadonlySet): void { + const { hostname } = url; + if (!isHostAllowed(hostname, allowedHosts)) { + let errorMessage = `URL with hostname "${hostname}" is not allowed.`; + if (typeof ngDevMode === 'undefined' || ngDevMode) { + errorMessage += + '\n\nAction Required: Update your "angular.json" to include this hostname. ' + + 'Path: "projects.[project-name].architect.build.options.security.allowedHosts".' + + '\n\nFor more information, see https://angular.dev/guide/ssr#configuring-allowed-hosts'; + } - const xForwardedProto = getFirstHeaderValue(headers.get('x-forwarded-proto')); - if (xForwardedProto && !VALID_PROTO_REGEX.test(xForwardedProto)) { - throw new Error('Header "x-forwarded-proto" must be either "http" or "https".'); + throw new Error(errorMessage); } } @@ -92,12 +94,12 @@ export function validateHeaders(request: Request, allowedHosts: ReadonlySet, ): void { - const value = getFirstHeaderValue(headers.get(headerName))?.replace(WWW_HOST_REGEX, ''); + const value = getFirstHeaderValue(headers.get(headerName)); if (!value) { return; } @@ -113,25 +115,34 @@ function validateHost( } const { hostname } = new URL(url); - if ( + if (!isHostAllowed(hostname, allowedHosts)) { + let errorMessage = `Header "${headerName}" with value "${value}" is not allowed.`; + if (typeof ngDevMode === 'undefined' || ngDevMode) { + errorMessage += + '\n\nAction Required: Update your "angular.json" to include this hostname. ' + + 'Path: "projects.[project-name].architect.build.options.security.allowedHosts".' + + '\n\nFor more information, see https://angular.dev/guide/ssr#configuring-allowed-hosts'; + } + + throw new Error(errorMessage); + } +} + +/** + * Checks if the hostname is allowed. + * @param hostname - The hostname to check. + * @param allowedHosts - A set of allowed hostnames. + * @returns `true` if the hostname is allowed, `false` otherwise. + */ +export function isHostAllowed(hostname: string, allowedHosts: ReadonlySet): boolean { + return ( // Check the provided allowed hosts first. allowedHosts.has(hostname) || checkWildcardHostnames(hostname, allowedHosts) || // Check the default allowed hosts last this is the fallback and should be rarely if ever used in production. DEFAULT_ALLOWED_HOSTS.has(hostname) || checkWildcardHostnames(hostname, DEFAULT_ALLOWED_HOSTS) - ) { - return; - } - - let errorMessage = `Header "${headerName}" with value "${value}" is not allowed.`; - if (typeof ngDevMode === 'undefined' || ngDevMode) { - errorMessage += - '\n\nAction Required: Update your "angular.json" to include this hostname. ' + - 'Path: "projects.[project-name].architect.build.options.security.allowedHosts".'; - } - - throw new Error(errorMessage); + ); } /** @@ -154,3 +165,30 @@ function checkWildcardHostnames(hostname: string, allowedHosts: ReadonlySet): void { + const headers = request.headers; + validateHostHeaders('x-forwarded-host', headers, allowedHosts); + validateHostHeaders('host', headers, allowedHosts); + + const xForwardedPort = getFirstHeaderValue(headers.get('x-forwarded-port')); + if (xForwardedPort && !VALID_PORT_REGEX.test(xForwardedPort)) { + throw new Error('Header "x-forwarded-port" must be a numeric value.'); + } + + const xForwardedProto = getFirstHeaderValue(headers.get('x-forwarded-proto')); + if (xForwardedProto && !VALID_PROTO_REGEX.test(xForwardedProto)) { + throw new Error('Header "x-forwarded-proto" must be either "http" or "https".'); + } +} diff --git a/packages/angular/ssr/test/utils/headers_spec.ts b/packages/angular/ssr/test/utils/validation_spec.ts similarity index 71% rename from packages/angular/ssr/test/utils/headers_spec.ts rename to packages/angular/ssr/test/utils/validation_spec.ts index ab539de4b7dc..c9af4e85d9f5 100644 --- a/packages/angular/ssr/test/utils/headers_spec.ts +++ b/packages/angular/ssr/test/utils/validation_spec.ts @@ -6,9 +6,9 @@ * found in the LICENSE file at https://angular.dev/license */ -import { validateHeaders } from '../../src/utils/headers'; +import { validateRequest, validateUrl } from '../../src/utils/validation'; -describe('validateHeaders', () => { +describe('validateRequest', () => { const allowedHosts = new Set(['example.com', 'sub.example.com']); it('should pass valid headers with allowed host', () => { @@ -21,7 +21,7 @@ describe('validateHeaders', () => { }, }); - expect(() => validateHeaders(request, allowedHosts)).not.toThrow(); + expect(() => validateRequest(request, allowedHosts)).not.toThrow(); }); it('should pass valid headers with localhost (default allowed)', () => { @@ -31,7 +31,7 @@ describe('validateHeaders', () => { }, }); - expect(() => validateHeaders(request, allowedHosts)).not.toThrow(); + expect(() => validateRequest(request, allowedHosts)).not.toThrow(); }); it('should throw error for disallowed host', () => { @@ -41,11 +41,13 @@ describe('validateHeaders', () => { }, }); - expect(() => validateHeaders(request, allowedHosts)).toThrowError( + expect(() => validateRequest(request, allowedHosts)).toThrowError( /Header "host" with value "evil\.com" is not allowed/, ); }); + // ... + it('should throw error for disallowed x-forwarded-host', () => { const request = new Request('https://example.com', { headers: { @@ -54,7 +56,7 @@ describe('validateHeaders', () => { }, }); - expect(() => validateHeaders(request, allowedHosts)).toThrowError( + expect(() => validateRequest(request, allowedHosts)).toThrowError( /Header "x-forwarded-host" with value "evil\.com" is not allowed/, ); }); @@ -67,7 +69,7 @@ describe('validateHeaders', () => { }, }); - expect(() => validateHeaders(request, allowedHosts)).toThrowError( + expect(() => validateRequest(request, allowedHosts)).toThrowError( 'Header "x-forwarded-host" contains path separators which is not allowed.', ); }); @@ -80,7 +82,7 @@ describe('validateHeaders', () => { }, }); - expect(() => validateHeaders(request, allowedHosts)).toThrowError( + expect(() => validateRequest(request, allowedHosts)).toThrowError( 'Header "x-forwarded-port" must be a numeric value.', ); }); @@ -93,7 +95,7 @@ describe('validateHeaders', () => { }, }); - expect(() => validateHeaders(request, allowedHosts)).toThrowError( + expect(() => validateRequest(request, allowedHosts)).toThrowError( 'Header "x-forwarded-proto" must be either "http" or "https".', ); }); @@ -106,7 +108,7 @@ describe('validateHeaders', () => { }, }); - expect(() => validateHeaders(request, allowedHosts)).not.toThrow(); + expect(() => validateRequest(request, allowedHosts)).not.toThrow(); }); it('should ignore port in host validation', () => { @@ -116,7 +118,7 @@ describe('validateHeaders', () => { }, }); - expect(() => validateHeaders(request, allowedHosts)).not.toThrow(); + expect(() => validateRequest(request, allowedHosts)).not.toThrow(); }); it('should throw if host header is completely malformed url', () => { @@ -126,7 +128,7 @@ describe('validateHeaders', () => { }, }); - expect(() => validateHeaders(request, allowedHosts)).toThrowError( + expect(() => validateRequest(request, allowedHosts)).toThrowError( 'Header "host" contains an invalid value.', ); }); @@ -141,7 +143,7 @@ describe('validateHeaders', () => { }, }); - expect(() => validateHeaders(request, wildcardHosts)).not.toThrow(); + expect(() => validateRequest(request, wildcardHosts)).not.toThrow(); }); it('should match nested subdomain', () => { @@ -151,7 +153,7 @@ describe('validateHeaders', () => { }, }); - expect(() => validateHeaders(request, wildcardHosts)).not.toThrow(); + expect(() => validateRequest(request, wildcardHosts)).not.toThrow(); }); it('should not match base domain', () => { @@ -161,7 +163,7 @@ describe('validateHeaders', () => { }, }); - expect(() => validateHeaders(request, wildcardHosts)).toThrowError( + expect(() => validateRequest(request, wildcardHosts)).toThrowError( /Header "host" with value "example\.com" is not allowed/, ); }); @@ -173,9 +175,26 @@ describe('validateHeaders', () => { }, }); - expect(() => validateHeaders(request, wildcardHosts)).toThrowError( + expect(() => validateRequest(request, wildcardHosts)).toThrowError( /Header "host" with value "evil\.com" is not allowed/, ); }); }); + + it('should pass valid URL with allowed host', () => { + const request = new Request('https://example.com/path'); + expect(() => validateRequest(request, allowedHosts)).not.toThrow(); + }); + + it('should pass valid URL with allowed sub-domain', () => { + const request = new Request('https://sub.example.com/path'); + expect(() => validateRequest(request, allowedHosts)).not.toThrow(); + }); + + it('should throw error for disallowed host', () => { + const request = new Request('https://evil.com/path'); + expect(() => validateRequest(request, allowedHosts)).toThrowError( + /URL with hostname "evil\.com" is not allowed/, + ); + }); });