diff --git a/packages/angular/ssr/src/app-engine.ts b/packages/angular/ssr/src/app-engine.ts index 4db30f1c1a26..8895890ba29c 100644 --- a/packages/angular/ssr/src/app-engine.ts +++ b/packages/angular/ssr/src/app-engine.ts @@ -23,7 +23,7 @@ import { */ export interface AngularAppEngineOptions { /** - * A set of allowed hostnames for the server application. + * A set of allowed hosts for the server application. */ allowedHosts?: readonly string[]; @@ -90,7 +90,7 @@ export class AngularAppEngine { private readonly manifest = getAngularAppEngineManifest(); /** - * A set of allowed hostnames for the server application. + * A set of allowed hosts for the server application. */ private readonly allowedHosts: ReadonlySet; diff --git a/packages/angular/ssr/src/manifest.ts b/packages/angular/ssr/src/manifest.ts index 21ded49b3e10..a72fa91103d3 100644 --- a/packages/angular/ssr/src/manifest.ts +++ b/packages/angular/ssr/src/manifest.ts @@ -75,7 +75,7 @@ export interface AngularAppEngineManifest { readonly supportedLocales: Readonly>; /** - * A readonly array of allowed hostnames. + * A readonly array of allowed hosts. */ readonly allowedHosts: Readonly; } diff --git a/packages/angular/ssr/src/utils/validation.ts b/packages/angular/ssr/src/utils/validation.ts index f1f7d741dff7..7099ef38c268 100644 --- a/packages/angular/ssr/src/utils/validation.ts +++ b/packages/angular/ssr/src/utils/validation.ts @@ -62,7 +62,7 @@ export function getFirstHeaderValue( * Validates a request. * * @param request - The incoming `Request` object to validate. - * @param allowedHosts - A set of allowed hostnames. + * @param allowedHosts - A set of allowed hosts. * @param disableHostCheck - Whether to disable the host check. * @throws Error if any of the validated headers contain invalid values. */ @@ -71,24 +71,24 @@ export function validateRequest( allowedHosts: ReadonlySet, disableHostCheck: boolean, ): void { - validateHeaders(request, allowedHosts, disableHostCheck); + const url = new URL(request.url); + validateHeaders(request, allowedHosts, disableHostCheck, url.protocol); if (!disableHostCheck) { - validateUrl(new URL(request.url), allowedHosts); + validateUrl(url, allowedHosts); } } /** - * Validates that the hostname of a given URL is allowed. + * Validates that the host 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. + * @param allowedHosts - A set of allowed hosts. + * @throws Error if the host is not in the allowlist. */ export function validateUrl(url: URL, allowedHosts: ReadonlySet): void { - const { hostname } = url; - if (!isHostAllowed(hostname, allowedHosts)) { - throw new Error(`URL with hostname "${hostname}" is not allowed.`); + if (!isHostAllowed(url, allowedHosts)) { + throw new Error(`URL with host "${url.host}" is not allowed.`); } } @@ -134,49 +134,46 @@ export function sanitizeRequestHeaders( * * @param headerName - The name of the header to validate (e.g., 'host', 'x-forwarded-host'). * @param headerValue - The value of the header to validate. - * @param allowedHosts - A set of allowed hostnames. + * @param allowedHosts - A set of allowed hosts. * @throws Error if the header value is invalid or the hostname is not in the allowlist. */ function verifyHostAllowed( headerName: string, headerValue: string, allowedHosts: ReadonlySet, + protocol: string, ): void { - const url = `http://${headerValue}`; + const url = `${protocol}//${headerValue}`; if (!URL.canParse(url)) { throw new Error(`Header "${headerName}" contains an invalid value and cannot be parsed.`); } - const { hostname, pathname, search, hash, username, password } = new URL(url); + const parsedUrl = new URL(url); + const { pathname, search, hash, username, password } = parsedUrl; if (pathname !== '/' || search || hash || username || password) { throw new Error( `Header "${headerName}" with value "${headerValue}" contains characters that are not allowed.`, ); } - if (!isHostAllowed(hostname, allowedHosts)) { + if (!isHostAllowed(parsedUrl, allowedHosts)) { throw new Error(`Header "${headerName}" with value "${headerValue}" is not allowed.`); } } /** - * 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. + * Checks if the host is allowed. + * @param url - The URL to check. + * @param allowedHosts - A set of allowed hosts. + * @returns `true` if the host is allowed, `false` otherwise. */ -function isHostAllowed(hostname: string, allowedHosts: ReadonlySet): boolean { - if (allowedHosts.has('*') || allowedHosts.has(hostname)) { +function isHostAllowed(url: URL, allowedHosts: ReadonlySet): boolean { + if (allowedHosts.has('*') || allowedHosts.has(url.host)) { return true; } for (const allowedHost of allowedHosts) { - if (!allowedHost.startsWith('*.')) { - continue; - } - - const domain = allowedHost.slice(1); - if (hostname.endsWith(domain)) { + if (isAllowedHostMatch(url, allowedHost)) { return true; } } @@ -184,11 +181,33 @@ function isHostAllowed(hostname: string, allowedHosts: ReadonlySet): boo return false; } +function isAllowedHostMatch(url: URL, allowedHost: string): boolean { + const wildcard = allowedHost.startsWith('*.'); + const comparableAllowedHost = wildcard ? `placeholder${allowedHost.slice(1)}` : allowedHost; + const allowedUrl = `${url.protocol}//${comparableAllowedHost}`; + + if (!URL.canParse(allowedUrl)) { + return false; + } + + const parsedAllowedUrl = new URL(allowedUrl); + if (url.port !== parsedAllowedUrl.port) { + return false; + } + + if (wildcard) { + const domain = parsedAllowedUrl.hostname.slice('placeholder'.length); + return url.hostname.endsWith(domain); + } + + return url.hostname === parsedAllowedUrl.hostname; +} + /** * Validates the headers of an incoming request. * * @param request - The incoming `Request` object containing the headers to validate. - * @param allowedHosts - A set of allowed hostnames. + * @param allowedHosts - A set of allowed hosts. * @param disableHostCheck - Whether to disable the host check. * @throws Error if any of the validated headers contain invalid values. */ @@ -196,12 +215,13 @@ function validateHeaders( request: Request, allowedHosts: ReadonlySet, disableHostCheck: boolean, + protocol: string, ): void { const headers = request.headers; for (const headerName of HOST_HEADERS_TO_VALIDATE) { const headerValue = getFirstHeaderValue(headers.get(headerName)); if (headerValue && !disableHostCheck) { - verifyHostAllowed(headerName, headerValue, allowedHosts); + verifyHostAllowed(headerName, headerValue, allowedHosts, protocol); } } diff --git a/packages/angular/ssr/test/app-engine_spec.ts b/packages/angular/ssr/test/app-engine_spec.ts index c4313be8a48c..22c94e0e1c59 100644 --- a/packages/angular/ssr/test/app-engine_spec.ts +++ b/packages/angular/ssr/test/app-engine_spec.ts @@ -351,9 +351,9 @@ describe('AngularAppEngine', () => { const response = await appEngine.handle(request); expect(response).not.toBeNull(); expect(response?.status).toBe(400); - expect(await response?.text()).toContain('URL with hostname "evil.com" is not allowed.'); + expect(await response?.text()).toContain('URL with host "evil.com" is not allowed.'); expect(consoleErrorSpy).toHaveBeenCalledWith( - jasmine.stringMatching('URL with hostname "evil.com" is not allowed.'), + jasmine.stringMatching('URL with host "evil.com" is not allowed.'), ); }); diff --git a/packages/angular/ssr/test/utils/validation_spec.ts b/packages/angular/ssr/test/utils/validation_spec.ts index 618b7f7ea2bf..d6d5be3e1fab 100644 --- a/packages/angular/ssr/test/utils/validation_spec.ts +++ b/packages/angular/ssr/test/utils/validation_spec.ts @@ -50,7 +50,7 @@ describe('Validation Utils', () => { it('should throw for disallowed hostname', () => { expect(() => validateUrl(new URL('http://evil.com'), allowedHosts)).toThrowError( - /URL with hostname "evil.com" is not allowed/, + /URL with host "evil.com" is not allowed/, ); }); @@ -61,7 +61,7 @@ describe('Validation Utils', () => { it('should not match base domain for wildcard (*.google.com vs google.com)', () => { // Logic: hostname.endsWith('.google.com') -> 'google.com'.endsWith('.google.com') is false expect(() => validateUrl(new URL('http://google.com'), allowedHosts)).toThrowError( - /URL with hostname "google.com" is not allowed/, + /URL with host "google.com" is not allowed/, ); }); @@ -71,6 +71,33 @@ describe('Validation Utils', () => { expect(() => validateUrl(new URL('http://google.com'), allowedHosts)).not.toThrow(); expect(() => validateUrl(new URL('http://evil.com'), allowedHosts)).not.toThrow(); }); + + it('should reject arbitrary ports on an allowed hostname', () => { + expect(() => validateUrl(new URL('http://example.com:8080'), allowedHosts)).toThrowError( + /URL with host "example.com:8080" is not allowed/, + ); + }); + + it('should pass for default ports on an allowed hostname', () => { + expect(() => validateUrl(new URL('http://example.com:80'), allowedHosts)).not.toThrow(); + expect(() => validateUrl(new URL('https://example.com:443'), allowedHosts)).not.toThrow(); + }); + + it('should pass for explicitly allowed hostname and port', () => { + const allowedHosts = new Set(['example.com:8080']); + expect(() => validateUrl(new URL('http://example.com:8080'), allowedHosts)).not.toThrow(); + expect(() => validateUrl(new URL('http://example.com:9090'), allowedHosts)).toThrowError( + /URL with host "example.com:9090" is not allowed/, + ); + }); + + it('should pass for explicitly allowed wildcard hostname and port', () => { + const allowedHosts = new Set(['*.google.com:8443']); + expect(() => validateUrl(new URL('https://foo.google.com:8443'), allowedHosts)).not.toThrow(); + expect(() => validateUrl(new URL('https://foo.google.com:9443'), allowedHosts)).toThrowError( + /URL with host "foo.google.com:9443" is not allowed/, + ); + }); }); describe('validateRequest', () => { @@ -97,10 +124,37 @@ describe('Validation Utils', () => { const req = new Request('http://evil.com'); expect(() => validateRequest(req, allowedHosts, false)).toThrowError( - /URL with hostname "evil.com" is not allowed/, + /URL with host "evil.com" is not allowed/, ); }); + it('should throw if URL port is not explicitly allowed', () => { + const req = new Request('http://example.com:8080'); + + expect(() => validateRequest(req, allowedHosts, false)).toThrowError( + /URL with host "example.com:8080" is not allowed/, + ); + }); + + it('should throw if x-forwarded-host uses an arbitrary port on an allowed hostname', () => { + const req = new Request('http://example.com:8080', { + headers: { 'x-forwarded-host': 'example.com:8080' }, + }); + + expect(() => validateRequest(req, allowedHosts, false)).toThrowError( + 'Header "x-forwarded-host" with value "example.com:8080" is not allowed.', + ); + }); + + it('should pass if x-forwarded-host uses an explicitly allowed port', () => { + const allowedHosts = new Set(['example.com:8080']); + const req = new Request('http://example.com:8080', { + headers: { 'x-forwarded-host': 'example.com:8080' }, + }); + + expect(() => validateRequest(req, allowedHosts, false)).not.toThrow(); + }); + it('should throw if x-forwarded-port is invalid', () => { const req = new Request('http://example.com', { headers: { 'x-forwarded-port': 'abc' },