From ec603bafb59f98696daecad5c7c4e4a7690077f5 Mon Sep 17 00:00:00 2001 From: Alan Agius <17563226+alan-agius4@users.noreply.github.com> Date: Mon, 16 Mar 2026 09:23:43 +0000 Subject: [PATCH 1/2] fix(@angular/ssr): disallow x-forwarded-prefix starting with a backslash Updated the INVALID_PREFIX_REGEX to ensure that prefixes starting with a backslash are considered invalid. Previously, only multiple slashes or dot segments were explicitly disallowed at the start. Also updated the associated validation error message and unit tests to reflect this change. --- packages/angular/ssr/src/utils/validation.ts | 4 ++-- packages/angular/ssr/test/utils/validation_spec.ts | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/angular/ssr/src/utils/validation.ts b/packages/angular/ssr/src/utils/validation.ts index dd89caf55592..cb1bf6ecb56b 100644 --- a/packages/angular/ssr/src/utils/validation.ts +++ b/packages/angular/ssr/src/utils/validation.ts @@ -29,7 +29,7 @@ const VALID_HOST_REGEX = /^[a-z0-9.:-]+$/i; /** * Regular expression to validate that the prefix is valid. */ -const INVALID_PREFIX_REGEX = /^[/\\]{2}|(?:^|[/\\])\.\.?(?:[/\\]|$)/; +const INVALID_PREFIX_REGEX = /^(?:\\|\/[/\\])|(?:^|[/\\])\.\.?(?:[/\\]|$)/; /** * Extracts the first value from a multi-value header string. @@ -270,7 +270,7 @@ function validateHeaders(request: Request): void { const xForwardedPrefix = getFirstHeaderValue(headers.get('x-forwarded-prefix')); if (xForwardedPrefix && INVALID_PREFIX_REGEX.test(xForwardedPrefix)) { throw new Error( - 'Header "x-forwarded-prefix" must not start with multiple "/" or "\\" or contain ".", ".." path segments.', + 'Header "x-forwarded-prefix" must not start with "\\" or multiple "/" or contain ".", ".." path segments.', ); } } diff --git a/packages/angular/ssr/test/utils/validation_spec.ts b/packages/angular/ssr/test/utils/validation_spec.ts index 6f8b5e170ec1..d8c3eaeebdb3 100644 --- a/packages/angular/ssr/test/utils/validation_spec.ts +++ b/packages/angular/ssr/test/utils/validation_spec.ts @@ -147,8 +147,8 @@ describe('Validation Utils', () => { ); }); - it('should throw error if x-forwarded-prefix starts with multiple slashes or backslashes', () => { - const inputs = ['//evil', '\\\\evil', '/\\evil', '\\/evil']; + it('should throw error if x-forwarded-prefix starts with a backslash or multiple slashes', () => { + const inputs = ['//evil', '\\\\evil', '/\\evil', '\\/evil', '\\evil']; for (const prefix of inputs) { const request = new Request('https://example.com', { @@ -160,7 +160,7 @@ describe('Validation Utils', () => { expect(() => validateRequest(request, allowedHosts, false)) .withContext(`Prefix: "${prefix}"`) .toThrowError( - 'Header "x-forwarded-prefix" must not start with multiple "/" or "\\" or contain ".", ".." path segments.', + 'Header "x-forwarded-prefix" must not start with "\\" or multiple "/" or contain ".", ".." path segments.', ); } }); @@ -193,7 +193,7 @@ describe('Validation Utils', () => { expect(() => validateRequest(request, allowedHosts, false)) .withContext(`Prefix: "${prefix}"`) .toThrowError( - 'Header "x-forwarded-prefix" must not start with multiple "/" or "\\" or contain ".", ".." path segments.', + 'Header "x-forwarded-prefix" must not start with "\\" or multiple "/" or contain ".", ".." path segments.', ); } }); From 9bb8f74664ec41fbee225612db3808aaba8dd03b Mon Sep 17 00:00:00 2001 From: Alan Agius <17563226+alan-agius4@users.noreply.github.com> Date: Mon, 16 Mar 2026 13:09:21 +0000 Subject: [PATCH 2/2] fix(@angular/ssr): support custom headers in redirect responses Updates createRedirectResponse to accept an optional Record of headers, allowing custom headers to be merged into the redirect response. The Location and Vary: X-Forwarded-Prefix headers are automatically set to ensure correct routing and proxy behavior. AngularServerApp now passes relevant headers from the matched route or response context when creating a redirect. --- packages/angular/ssr/src/app.ts | 5 +- packages/angular/ssr/src/utils/redirect.ts | 28 +++++++-- .../angular/ssr/test/utils/redirect_spec.ts | 60 +++++++++++++++++++ 3 files changed, 87 insertions(+), 6 deletions(-) create mode 100644 packages/angular/ssr/test/utils/redirect_spec.ts diff --git a/packages/angular/ssr/src/app.ts b/packages/angular/ssr/src/app.ts index 96afaa44c8d6..0c381c08ae88 100644 --- a/packages/angular/ssr/src/app.ts +++ b/packages/angular/ssr/src/app.ts @@ -190,7 +190,7 @@ export class AngularServerApp { return null; } - const { redirectTo, status, renderMode } = matchedRoute; + const { redirectTo, status, renderMode, headers } = matchedRoute; if (redirectTo !== undefined) { return createRedirectResponse( @@ -199,6 +199,7 @@ export class AngularServerApp { buildPathWithParams(redirectTo, url.pathname), ), status, + headers, ); } @@ -352,7 +353,7 @@ export class AngularServerApp { } if (result.redirectTo) { - return createRedirectResponse(result.redirectTo, responseInit.status); + return createRedirectResponse(result.redirectTo, responseInit.status, headers); } if (renderMode === RenderMode.Prerender) { diff --git a/packages/angular/ssr/src/utils/redirect.ts b/packages/angular/ssr/src/utils/redirect.ts index 18ca3f92b819..3da6a0232bd8 100644 --- a/packages/angular/ssr/src/utils/redirect.ts +++ b/packages/angular/ssr/src/utils/redirect.ts @@ -27,9 +27,14 @@ export function isValidRedirectResponseCode(code: number): boolean { * @param location - The URL to which the response should redirect. * @param status - The HTTP status code for the redirection. Defaults to 302 (Found). * See: https://developer.mozilla.org/en-US/docs/Web/API/Response/redirect_static#status + * @param headers - Additional headers to include in the response. * @returns A `Response` object representing the HTTP redirect. */ -export function createRedirectResponse(location: string, status = 302): Response { +export function createRedirectResponse( + location: string, + status = 302, + headers?: Record, +): Response { if (ngDevMode && !isValidRedirectResponseCode(status)) { throw new Error( `Invalid redirect status code: ${status}. ` + @@ -37,10 +42,25 @@ export function createRedirectResponse(location: string, status = 302): Response ); } + const resHeaders = new Headers(headers); + if (ngDevMode && resHeaders.has('location')) { + // eslint-disable-next-line no-console + console.warn( + `Location header "${resHeaders.get('location')}" will ignored and set to "${location}".`, + ); + } + + let vary = resHeaders.get('Vary') ?? ''; + if (vary) { + vary += ', '; + } + vary += 'X-Forwarded-Prefix'; + + resHeaders.set('Vary', vary); + resHeaders.set('Location', location); + return new Response(null, { status, - headers: { - 'Location': location, - }, + headers: resHeaders, }); } diff --git a/packages/angular/ssr/test/utils/redirect_spec.ts b/packages/angular/ssr/test/utils/redirect_spec.ts new file mode 100644 index 000000000000..bddbb81e2723 --- /dev/null +++ b/packages/angular/ssr/test/utils/redirect_spec.ts @@ -0,0 +1,60 @@ +/** + * @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 { createRedirectResponse } from '../../src/utils/redirect'; + +describe('Redirect Utils', () => { + describe('createRedirectResponse', () => { + it('should create a redirect response with default status 302', () => { + const response = createRedirectResponse('/home'); + expect(response.status).toBe(302); + expect(response.headers.get('Location')).toBe('/home'); + expect(response.headers.get('Vary')).toBe('X-Forwarded-Prefix'); + }); + + it('should create a redirect response with a custom status', () => { + const response = createRedirectResponse('/home', 301); + expect(response.status).toBe(301); + expect(response.headers.get('Location')).toBe('/home'); + }); + + it('should allow providing additional headers', () => { + const response = createRedirectResponse('/home', 302, { 'X-Custom': 'value' }); + expect(response.headers.get('X-Custom')).toBe('value'); + expect(response.headers.get('Location')).toBe('/home'); + expect(response.headers.get('Vary')).toBe('X-Forwarded-Prefix'); + }); + + it('should append to Vary header instead of overriding it', () => { + const response = createRedirectResponse('/home', 302, { + 'Location': '/evil', + 'Vary': 'Host', + }); + expect(response.headers.get('Location')).toBe('/home'); + expect(response.headers.get('Vary')).toBe('Host, X-Forwarded-Prefix'); + }); + + it('should warn if Location header is provided in extra headers in dev mode', () => { + // @ts-expect-error accessing global + globalThis.ngDevMode = true; + const warnSpy = spyOn(console, 'warn'); + createRedirectResponse('/home', 302, { 'Location': '/evil' }); + expect(warnSpy).toHaveBeenCalledWith( + 'Location header "/evil" will ignored and set to "/home".', + ); + }); + + it('should throw error for invalid redirect status code in dev mode', () => { + // @ts-expect-error accessing global + globalThis.ngDevMode = true; + expect(() => createRedirectResponse('/home', 200)).toThrowError( + /Invalid redirect status code: 200/, + ); + }); + }); +});