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..95c13ec97841 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 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": { + "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..34c2e334b52c 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,7 @@ export function generateAngularServerAppEngineManifest( const manifestContent = ` export default { basePath: '${basePath}', + 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 673156ee6b43..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 { @@ -31,6 +32,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 +68,11 @@ 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); - constructor(private options?: CommonEngineOptions | undefined) { attachNodeGlobalErrorHandlers(); } @@ -74,6 +81,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 +113,19 @@ 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; + if (!isHostAllowed(hostname, this.allowedHosts)) { + 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..402ec29ba56d 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/validation'; /** * 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..80bed8554065 100644 --- a/packages/angular/ssr/src/app-engine.ts +++ b/packages/angular/ssr/src/app-engine.ts @@ -11,6 +11,7 @@ import { Hooks } from './hooks'; import { getPotentialLocaleIdFromUrl, getPreferredLocale } from './i18n'; import { EntryPointExports, getAngularAppEngineManifest } from './manifest'; import { joinUrlParts } from './utils/url'; +import { validateRequest } from './utils/validation'; /** * Angular server application engine. @@ -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 { + validateRequest(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/validation.ts b/packages/angular/ssr/src/utils/validation.ts new file mode 100644 index 000000000000..355f03ca62d0 --- /dev/null +++ b/packages/angular/ssr/src/utils/validation.ts @@ -0,0 +1,194 @@ +/** + * @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 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 a request. + * + * @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 validateRequest(request: Request, allowedHosts: ReadonlySet): void { + validateHeaders(request, allowedHosts); + validateUrl(new URL(request.url), allowedHosts); +} + +/** + * 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'; + } + + throw new Error(errorMessage); + } +} + +/** + * 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 validateHostHeaders( + headerName: string, + headers: Headers, + allowedHosts: ReadonlySet, +): void { + const value = getFirstHeaderValue(headers.get(headerName)); + 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 (!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) + ); +} + +/** + * 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; +} + +/** + * 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. + */ +function validateHeaders(request: Request, 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/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/validation_spec.ts b/packages/angular/ssr/test/utils/validation_spec.ts new file mode 100644 index 000000000000..c9af4e85d9f5 --- /dev/null +++ b/packages/angular/ssr/test/utils/validation_spec.ts @@ -0,0 +1,200 @@ +/** + * @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 { validateRequest, validateUrl } from '../../src/utils/validation'; + +describe('validateRequest', () => { + 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(() => validateRequest(request, allowedHosts)).not.toThrow(); + }); + + it('should pass valid headers with localhost (default allowed)', () => { + const request = new Request('https://localhost', { + headers: { + 'host': 'localhost', + }, + }); + + expect(() => validateRequest(request, allowedHosts)).not.toThrow(); + }); + + it('should throw error for disallowed host', () => { + const request = new Request('https://evil.com', { + headers: { + 'host': 'evil.com', + }, + }); + + 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: { + 'host': 'example.com', + 'x-forwarded-host': 'evil.com', + }, + }); + + expect(() => validateRequest(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(() => validateRequest(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(() => validateRequest(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(() => validateRequest(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(() => validateRequest(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(() => validateRequest(request, allowedHosts)).not.toThrow(); + }); + + it('should throw if host header is completely malformed url', () => { + const request = new Request('https://example.com', { + headers: { + 'host': '[', + }, + }); + + expect(() => validateRequest(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(() => validateRequest(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(() => validateRequest(request, wildcardHosts)).not.toThrow(); + }); + + it('should not match base domain', () => { + const request = new Request('https://example.com', { + headers: { + 'host': 'example.com', + }, + }); + + expect(() => validateRequest(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(() => 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/, + ); + }); +}); 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);