From 55401cab8cbb0fe06f483a773511b1162f90b8e4 Mon Sep 17 00:00:00 2001 From: Alan Agius <17563226+alan-agius4@users.noreply.github.com> Date: Wed, 25 Feb 2026 13:55:34 +0000 Subject: [PATCH] refactor(@angular/ssr): remove CSR fallback for invalid hosts Previously, when a request contained an unrecognized host header, the server would fallback to serving the client-side application (CSR) as a temporary migration path. This commit removes this fallback behavior. Requests with invalid or unrecognized host headers will now strictly return a 400 Bad Request response. BREAKING CHANGE: The server no longer falls back to Client-Side Rendering (CSR) when a request fails host validation. Requests with unrecognized 'Host' headers will now return a 400 Bad Request status code. Users must ensure all valid hosts are correctly configured in the 'allowedHosts' option. --- packages/angular/ssr/node/src/app-engine.ts | 3 +- .../node/src/common-engine/common-engine.ts | 17 -- packages/angular/ssr/src/app-engine.ts | 43 ++-- packages/angular/ssr/src/app.ts | 21 -- packages/angular/ssr/test/app-engine_spec.ts | 191 +++++++----------- 5 files changed, 85 insertions(+), 190 deletions(-) diff --git a/packages/angular/ssr/node/src/app-engine.ts b/packages/angular/ssr/node/src/app-engine.ts index 29eb1fd366ab..7f7d3017a846 100644 --- a/packages/angular/ssr/node/src/app-engine.ts +++ b/packages/angular/ssr/node/src/app-engine.ts @@ -60,8 +60,7 @@ export class AngularNodeAppEngine { * @remarks * To prevent potential Server-Side Request Forgery (SSRF), this function verifies the hostname * of the `request.url` against a list of authorized hosts. - * If the hostname is not recognized and `allowedHosts` is not empty, a Client-Side Rendered (CSR) version of the - * page is returned otherwise a 400 Bad Request is returned. + * If the hostname is not recognized a 400 Bad Request is returned. * * Resolution: * Authorize your hostname by configuring `allowedHosts` in `angular.json` in: 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 1c130d9abe86..b44c2c5255ca 100644 --- a/packages/angular/ssr/node/src/common-engine/common-engine.ts +++ b/packages/angular/ssr/node/src/common-engine/common-engine.ts @@ -92,29 +92,12 @@ export class CommonEngine { try { validateUrl(urlObj, this.allowedHosts); } catch (error) { - const isAllowedHostConfigured = this.allowedHosts.size > 0; // eslint-disable-next-line no-console console.error( `ERROR: ${(error as Error).message}` + 'Please provide a list of allowed hosts in the "allowedHosts" option in the "CommonEngine" constructor.', - isAllowedHostConfigured - ? '' - : '\nFalling back to client side rendering. This will become a 400 Bad Request in a future major version.', ); - if (!isAllowedHostConfigured) { - // Fallback to CSR to avoid a breaking change. - // TODO(alanagius): Return a 400 and remove this fallback in the next major version (v22). - let document = opts.document; - if (!document && opts.documentFilePath) { - document = opts.document ?? (await this.getDocument(opts.documentFilePath)); - } - - if (document) { - return document; - } - } - throw error; } } diff --git a/packages/angular/ssr/src/app-engine.ts b/packages/angular/ssr/src/app-engine.ts index 0ba82002dcef..3b9d468d70b9 100644 --- a/packages/angular/ssr/src/app-engine.ts +++ b/packages/angular/ssr/src/app-engine.ts @@ -95,8 +95,8 @@ export class AngularAppEngine { * @remarks * To prevent potential Server-Side Request Forgery (SSRF), this function verifies the hostname * of the `request.url` against a list of authorized hosts. - * If the hostname is not recognized and `allowedHosts` is not empty, a Client-Side Rendered (CSR) version of the - * page is returned otherwise a 400 Bad Request is returned. + * If the hostname is not recognized a 400 Bad Request is returned. + * * Resolution: * Authorize your hostname by configuring `allowedHosts` in `angular.json` in: * `projects.[project-name].architect.build.options.security.allowedHosts`. @@ -110,7 +110,7 @@ export class AngularAppEngine { try { validateRequest(request, allowedHost); } catch (error) { - return this.handleValidationError(error as Error, request); + return this.handleValidationError(request.url, error as Error); } // Clone request with patched headers to prevent unallowed host header access. @@ -120,7 +120,9 @@ export class AngularAppEngine { const serverApp = await this.getAngularServerAppForRequest(securedRequest); if (serverApp) { return Promise.race([ - onHeaderValidationError.then((error) => this.handleValidationError(error, securedRequest)), + onHeaderValidationError.then((error) => + this.handleValidationError(securedRequest.url, error), + ), serverApp.handle(securedRequest, requestContext), ]); } @@ -255,38 +257,23 @@ export class AngularAppEngine { /** * Handles validation errors by logging the error and returning an appropriate response. * + * @param url - The URL of the request. * @param error - The validation error to handle. - * @param request - The HTTP request that caused the validation error. - * @returns A promise that resolves to a `Response` object with a 400 status code if allowed hosts are configured, - * or `null` if allowed hosts are not configured (in which case the request is served client-side). + * @returns A `Response` object with a 400 status code. */ - private async handleValidationError(error: Error, request: Request): Promise { - const isAllowedHostConfigured = this.allowedHosts.size > 0; + private handleValidationError(url: string, error: Error): Response { const errorMessage = error.message; - // eslint-disable-next-line no-console console.error( - `ERROR: Bad Request ("${request.url}").\n` + + `ERROR: Bad Request ("${url}").\n` + errorMessage + - (isAllowedHostConfigured - ? '' - : '\nFalling back to client side rendering. This will become a 400 Bad Request in a future major version.') + '\n\nFor more information, see https://angular.dev/best-practices/security#preventing-server-side-request-forgery-ssrf', ); - if (isAllowedHostConfigured) { - // Allowed hosts has been configured incorrectly, thus we can return a 400 bad request. - return new Response(errorMessage, { - status: 400, - statusText: 'Bad Request', - headers: { 'Content-Type': 'text/plain' }, - }); - } - - // Fallback to CSR to avoid a breaking change. - // TODO(alanagius): Return a 400 and remove this fallback in the next major version (v22). - const serverApp = await this.getAngularServerAppForRequest(request); - - return serverApp?.serveClientSidePage() ?? null; + return new Response(errorMessage, { + status: 400, + statusText: 'Bad Request', + headers: { 'Content-Type': 'text/plain' }, + }); } } diff --git a/packages/angular/ssr/src/app.ts b/packages/angular/ssr/src/app.ts index 76296ebe737d..96afaa44c8d6 100644 --- a/packages/angular/ssr/src/app.ts +++ b/packages/angular/ssr/src/app.ts @@ -484,27 +484,6 @@ export class AngularServerApp { return html; } - - /** - * Serves the client-side version of the application. - * TODO(alanagius): Remove this method in version 22. - * @internal - */ - async serveClientSidePage(): Promise { - const { - manifest: { locale }, - assets, - } = this; - - const html = await assets.getServerAsset('index.csr.html').text(); - - return new Response(html, { - headers: new Headers({ - 'Content-Type': 'text/html;charset=UTF-8', - ...(locale !== undefined ? { 'Content-Language': locale } : {}), - }), - }); - } } let angularServerApp: AngularServerApp | undefined; diff --git a/packages/angular/ssr/test/app-engine_spec.ts b/packages/angular/ssr/test/app-engine_spec.ts index 29d638a8c13f..7693c6158a38 100644 --- a/packages/angular/ssr/test/app-engine_spec.ts +++ b/packages/angular/ssr/test/app-engine_spec.ts @@ -290,140 +290,87 @@ describe('AngularAppEngine', () => { describe('Invalid host headers', () => { let consoleErrorSpy: jasmine.Spy; - describe('with allowed hosts configured', () => { - beforeAll(() => { - setAngularAppEngineManifest({ - allowedHosts: ['example.com'], - entryPoints: { - '': async () => { - setAngularAppTestingManifest( - [{ path: 'home', component: TestHomeComponent }], - [{ path: '**', renderMode: RenderMode.Server }], - ); - - return { - ɵgetOrCreateAngularServerApp: getOrCreateAngularServerApp, - ɵdestroyAngularServerApp: destroyAngularServerApp, - }; - }, - }, - basePath: '/', - supportedLocales: { 'en-US': '' }, - }); - - appEngine = new AngularAppEngine(); - }); - - beforeEach(() => { - consoleErrorSpy = spyOn(console, 'error'); - }); - - it('should return 400 when disallowed host', async () => { - const request = new Request('https://evil.com'); - 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(consoleErrorSpy).toHaveBeenCalledWith( - jasmine.stringMatching('URL with hostname "evil.com" is not allowed.'), - ); - }); - - it('should return 400 when disallowed host header', async () => { - const request = new Request('https://example.com/home', { - headers: { 'host': 'evil.com' }, - }); - const response = await appEngine.handle(request); - expect(response).not.toBeNull(); - expect(response?.status).toBe(400); - expect(await response?.text()).toContain( - 'Header "host" with value "evil.com" is not allowed.', - ); - expect(consoleErrorSpy).toHaveBeenCalledWith( - jasmine.stringMatching('Header "host" with value "evil.com" is not allowed.'), - ); - }); + beforeAll(() => { + setAngularAppEngineManifest({ + allowedHosts: ['example.com'], + entryPoints: { + '': async () => { + setAngularAppTestingManifest( + [{ path: 'home', component: TestHomeComponent }], + [{ path: '**', renderMode: RenderMode.Server }], + ); - it('should return 400 when disallowed x-forwarded-host header', async () => { - const request = new Request('https://example.com/home', { - headers: { 'x-forwarded-host': 'evil.com' }, - }); - const response = await appEngine.handle(request); - expect(response).not.toBeNull(); - expect(response?.status).toBe(400); - expect(await response?.text()).toContain( - 'Header "x-forwarded-host" with value "evil.com" is not allowed.', - ); - expect(consoleErrorSpy).toHaveBeenCalledWith( - jasmine.stringMatching('Header "x-forwarded-host" with value "evil.com" is not allowed.'), - ); + return { + ɵgetOrCreateAngularServerApp: getOrCreateAngularServerApp, + ɵdestroyAngularServerApp: destroyAngularServerApp, + }; + }, + }, + basePath: '/', + supportedLocales: { 'en-US': '' }, }); - it('should return 400 when host with path separator', async () => { - const request = new Request('https://example.com/home', { - headers: { 'host': 'example.com/evil' }, - }); - const response = await appEngine.handle(request); - expect(response).not.toBeNull(); - expect(response?.status).toBe(400); - expect(await response?.text()).toContain( - 'Header "host" contains characters that are not allowed.', - ); - expect(consoleErrorSpy).toHaveBeenCalledWith( - jasmine.stringMatching('Header "host" contains characters that are not allowed.'), - ); - }); + appEngine = new AngularAppEngine(); }); - describe('without allowed hosts configured', () => { - beforeAll(() => { - setAngularAppEngineManifest({ - allowedHosts: [], - entryPoints: { - '': async () => { - setAngularAppTestingManifest( - [{ path: 'home', component: TestHomeComponent }], - [{ path: '**', renderMode: RenderMode.Server }], - ); - - return { - ɵgetOrCreateAngularServerApp: getOrCreateAngularServerApp, - ɵdestroyAngularServerApp: destroyAngularServerApp, - }; - }, - }, - basePath: '/', - supportedLocales: { 'en-US': '' }, - }); + beforeEach(() => { + consoleErrorSpy = spyOn(console, 'error'); + }); - appEngine = new AngularAppEngine(); - }); + it('should return 400 when disallowed host', async () => { + const request = new Request('https://evil.com'); + 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(consoleErrorSpy).toHaveBeenCalledWith( + jasmine.stringMatching('URL with hostname "evil.com" is not allowed.'), + ); + }); - beforeEach(() => { - consoleErrorSpy = spyOn(console, 'error'); + it('should return 400 when disallowed host header', async () => { + const request = new Request('https://example.com/home', { + headers: { 'host': 'evil.com' }, }); + const response = await appEngine.handle(request); + expect(response).not.toBeNull(); + expect(response?.status).toBe(400); + expect(await response?.text()).toContain( + 'Header "host" with value "evil.com" is not allowed.', + ); + expect(consoleErrorSpy).toHaveBeenCalledWith( + jasmine.stringMatching('Header "host" with value "evil.com" is not allowed.'), + ); + }); - it('should log error and fallback to CSR when disallowed host', async () => { - const request = new Request('https://example.com'); - const response = await appEngine.handle(request); - expect(response).not.toBeNull(); - expect(await response?.text()).toContain('CSR page'); - expect(consoleErrorSpy).toHaveBeenCalledWith( - jasmine.stringMatching('URL with hostname "example.com" is not allowed.'), - ); + it('should return 400 when disallowed x-forwarded-host header', async () => { + const request = new Request('https://example.com/home', { + headers: { 'x-forwarded-host': 'evil.com' }, }); + const response = await appEngine.handle(request); + expect(response).not.toBeNull(); + expect(response?.status).toBe(400); + expect(await response?.text()).toContain( + 'Header "x-forwarded-host" with value "evil.com" is not allowed.', + ); + expect(consoleErrorSpy).toHaveBeenCalledWith( + jasmine.stringMatching('Header "x-forwarded-host" with value "evil.com" is not allowed.'), + ); + }); - it('should log error and fallback to CSR when host with path separator', async () => { - const request = new Request('https://example.com/home', { - headers: { 'host': 'example.com/evil' }, - }); - const response = await appEngine.handle(request); - expect(response).not.toBeNull(); - expect(await response?.text()).toContain('CSR page'); - expect(consoleErrorSpy).toHaveBeenCalledWith( - jasmine.stringMatching('Header "host" contains characters that are not allowed.'), - ); + it('should return 400 when host with path separator', async () => { + const request = new Request('https://example.com/home', { + headers: { 'host': 'example.com/evil' }, }); + const response = await appEngine.handle(request); + expect(response).not.toBeNull(); + expect(response?.status).toBe(400); + expect(await response?.text()).toContain( + 'Header "host" contains characters that are not allowed.', + ); + expect(consoleErrorSpy).toHaveBeenCalledWith( + jasmine.stringMatching('Header "host" contains characters that are not allowed.'), + ); }); }); });