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 083008f17050..557b2d34b52a 100644 --- a/packages/angular/build/src/builders/dev-server/vite/index.ts +++ b/packages/angular/build/src/builders/dev-server/vite/index.ts @@ -101,7 +101,9 @@ export async function* serveWithVite( // Angular SSR supports `*.`. const allowedHosts = Array.isArray(serverOptions.allowedHosts) ? serverOptions.allowedHosts.map((host) => (host[0] === '.' ? '*' + host : host)) - : []; + : serverOptions.allowedHosts === true + ? ['*'] + : []; // Always allow the dev server host allowedHosts.push(serverOptions.host); diff --git a/packages/angular/ssr/src/app-engine.ts b/packages/angular/ssr/src/app-engine.ts index f7babe9beaf7..09e1093fef72 100644 --- a/packages/angular/ssr/src/app-engine.ts +++ b/packages/angular/ssr/src/app-engine.ts @@ -88,7 +88,22 @@ export class AngularAppEngine { * @param options Options for the Angular server application engine. */ constructor(options?: AngularAppEngineOptions) { - this.allowedHosts = new Set([...(options?.allowedHosts ?? []), ...this.manifest.allowedHosts]); + this.allowedHosts = this.getAllowedHosts(options); + } + + private getAllowedHosts(options: AngularAppEngineOptions | undefined): ReadonlySet { + const allowedHosts = new Set([...(options?.allowedHosts ?? []), ...this.manifest.allowedHosts]); + + if (allowedHosts.has('*')) { + // eslint-disable-next-line no-console + console.warn( + 'Allowing all hosts via "*" is a security risk. This configuration should only be used when ' + + 'validation for "Host" and "X-Forwarded-Host" headers is performed in another layer, such as a load balancer or reverse proxy. ' + + 'For more information see: https://angular.dev/best-practices/security#preventing-server-side-request-forgery-ssrf', + ); + } + + return allowedHosts; } /** diff --git a/packages/angular/ssr/src/utils/validation.ts b/packages/angular/ssr/src/utils/validation.ts index cb1bf6ecb56b..e359b94aac6f 100644 --- a/packages/angular/ssr/src/utils/validation.ts +++ b/packages/angular/ssr/src/utils/validation.ts @@ -224,7 +224,7 @@ function verifyHostAllowed( * @returns `true` if the hostname is allowed, `false` otherwise. */ function isHostAllowed(hostname: string, allowedHosts: ReadonlySet): boolean { - if (allowedHosts.has(hostname)) { + if (allowedHosts.has('*') || allowedHosts.has(hostname)) { return true; } diff --git a/packages/angular/ssr/test/utils/validation_spec.ts b/packages/angular/ssr/test/utils/validation_spec.ts index d8c3eaeebdb3..8fed87e83713 100644 --- a/packages/angular/ssr/test/utils/validation_spec.ts +++ b/packages/angular/ssr/test/utils/validation_spec.ts @@ -64,6 +64,13 @@ describe('Validation Utils', () => { /URL with hostname "google.com" is not allowed/, ); }); + + it('should pass for all hostnames when "*" is used', () => { + const allowedHosts = new Set(['*']); + expect(() => validateUrl(new URL('http://example.com'), allowedHosts)).not.toThrow(); + expect(() => validateUrl(new URL('http://google.com'), allowedHosts)).not.toThrow(); + expect(() => validateUrl(new URL('http://evil.com'), allowedHosts)).not.toThrow(); + }); }); describe('validateRequest', () => { @@ -242,6 +249,15 @@ describe('Validation Utils', () => { expect(secured.headers.get('host')).toBe('example.com'); }); + it('should allow any host header when "*" is used', () => { + const allowedHosts = new Set(['*']); + const req = new Request('http://example.com', { + headers: { 'host': 'evil.com' }, + }); + const { request: secured } = cloneRequestAndPatchHeaders(req, allowedHosts); + expect(secured.headers.get('host')).toBe('evil.com'); + }); + it('should validate x-forwarded-host header', async () => { const req = new Request('http://example.com', { headers: { 'x-forwarded-host': 'evil.com' },