From 1a12ab3cb606124945ef9162b6e93936f61db19d 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/3] 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 c89cdd6a64ed..9e83e144b347 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. @@ -262,7 +262,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 10ab896e36f1..acf1e4829e8e 100644 --- a/packages/angular/ssr/test/utils/validation_spec.ts +++ b/packages/angular/ssr/test/utils/validation_spec.ts @@ -125,8 +125,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', { @@ -138,7 +138,7 @@ describe('Validation Utils', () => { expect(() => validateRequest(request, allowedHosts)) .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.', ); } }); @@ -171,7 +171,7 @@ describe('Validation Utils', () => { expect(() => validateRequest(request, allowedHosts)) .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 c062207d526b7ca49f57cb2daeb71cd936e9bc5c Mon Sep 17 00:00:00 2001 From: Alan Agius <17563226+alan-agius4@users.noreply.github.com> Date: Tue, 17 Mar 2026 10:09:28 +0000 Subject: [PATCH 2/3] 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 | 23 ++----- packages/angular/ssr/src/utils/redirect.ts | 66 +++++++++++++++++++ .../angular/ssr/test/utils/redirect_spec.ts | 60 +++++++++++++++++ 3 files changed, 130 insertions(+), 19 deletions(-) create mode 100644 packages/angular/ssr/src/utils/redirect.ts 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 85227a9d8a33..d3e3b1dfa7bf 100644 --- a/packages/angular/ssr/src/app.ts +++ b/packages/angular/ssr/src/app.ts @@ -25,6 +25,7 @@ import { InlineCriticalCssProcessor } from './utils/inline-critical-css'; import { LRUCache } from './utils/lru-cache'; import { AngularBootstrap, renderAngular } from './utils/ng'; import { promiseWithAbort } from './utils/promise'; +import { createRedirectResponse } from './utils/redirect'; import { buildPathWithParams, joinUrlParts, stripLeadingSlash } from './utils/url'; /** @@ -174,7 +175,7 @@ export class AngularServerApp { return null; } - const { redirectTo, status, renderMode } = matchedRoute; + const { redirectTo, status, renderMode, headers } = matchedRoute; if (redirectTo !== undefined) { return createRedirectResponse( @@ -183,6 +184,7 @@ export class AngularServerApp { buildPathWithParams(redirectTo, url.pathname), ), status, + headers, ); } @@ -336,7 +338,7 @@ export class AngularServerApp { } if (result.redirectTo) { - return createRedirectResponse(result.redirectTo, status); + return createRedirectResponse(result.redirectTo, responseInit.status, headers); } if (renderMode === RenderMode.Prerender) { @@ -552,20 +554,3 @@ function appendPreloadHintsToHtml(html: string, preload: readonly string[]): str html.slice(bodyCloseIdx), ].join('\n'); } - -/** - * Creates an HTTP redirect response with a specified location and status code. - * - * @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 - * @returns A `Response` object representing the HTTP redirect. - */ -function createRedirectResponse(location: string, status = 302): Response { - return new Response(null, { - status, - headers: { - 'Location': location, - }, - }); -} diff --git a/packages/angular/ssr/src/utils/redirect.ts b/packages/angular/ssr/src/utils/redirect.ts new file mode 100644 index 000000000000..3da6a0232bd8 --- /dev/null +++ b/packages/angular/ssr/src/utils/redirect.ts @@ -0,0 +1,66 @@ +/** + * @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 + */ + +/** + * An set of HTTP status codes that are considered valid for redirect responses. + */ +export const VALID_REDIRECT_RESPONSE_CODES: Set = new Set([301, 302, 303, 307, 308]); + +/** + * Checks if the given HTTP status code is a valid redirect response code. + * + * @param code The HTTP status code to check. + * @returns `true` if the code is a valid redirect response code, `false` otherwise. + */ +export function isValidRedirectResponseCode(code: number): boolean { + return VALID_REDIRECT_RESPONSE_CODES.has(code); +} + +/** + * Creates an HTTP redirect response with a specified location and status code. + * + * @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, + headers?: Record, +): Response { + if (ngDevMode && !isValidRedirectResponseCode(status)) { + throw new Error( + `Invalid redirect status code: ${status}. ` + + `Please use one of the following redirect response codes: ${[...VALID_REDIRECT_RESPONSE_CODES.values()].join(', ')}.`, + ); + } + + 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: 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/, + ); + }); + }); +}); From a725e7b1aefaf6642b45d526c1870ca559ff1c28 Mon Sep 17 00:00:00 2001 From: Alan Agius <17563226+alan-agius4@users.noreply.github.com> Date: Tue, 17 Mar 2026 10:20:09 +0000 Subject: [PATCH 3/3] fix(@angular/ssr): ensure unique values in redirect response Vary header Refactors the `createRedirectResponse` function to use a `Set` for constructing the `Vary` header. This ensures that `X-Forwarded-Prefix` is always present exactly once, and that existing `Vary` values from provided headers are correctly parsed, deduplicated, and preserved. Updates the associated unit tests to reflect the new header order and verify the deduplication logic. --- packages/angular/ssr/src/utils/redirect.ts | 15 ++++++++++----- packages/angular/ssr/test/utils/redirect_spec.ts | 9 ++++++++- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/packages/angular/ssr/src/utils/redirect.ts b/packages/angular/ssr/src/utils/redirect.ts index 3da6a0232bd8..79fb10f424dc 100644 --- a/packages/angular/ssr/src/utils/redirect.ts +++ b/packages/angular/ssr/src/utils/redirect.ts @@ -50,13 +50,18 @@ export function createRedirectResponse( ); } - let vary = resHeaders.get('Vary') ?? ''; - if (vary) { - vary += ', '; + // Ensure unique values for Vary header + const varyArray = resHeaders.get('Vary')?.split(',') ?? []; + const varySet = new Set(['X-Forwarded-Prefix']); + for (const vary of varyArray) { + const value = vary.trim(); + + if (value) { + varySet.add(value); + } } - vary += 'X-Forwarded-Prefix'; - resHeaders.set('Vary', vary); + resHeaders.set('Vary', [...varySet].join(', ')); resHeaders.set('Location', location); return new Response(null, { diff --git a/packages/angular/ssr/test/utils/redirect_spec.ts b/packages/angular/ssr/test/utils/redirect_spec.ts index bddbb81e2723..b26edd458ac3 100644 --- a/packages/angular/ssr/test/utils/redirect_spec.ts +++ b/packages/angular/ssr/test/utils/redirect_spec.ts @@ -36,7 +36,14 @@ describe('Redirect Utils', () => { 'Vary': 'Host', }); expect(response.headers.get('Location')).toBe('/home'); - expect(response.headers.get('Vary')).toBe('Host, X-Forwarded-Prefix'); + expect(response.headers.get('Vary')).toBe('X-Forwarded-Prefix, Host'); + }); + + it('should NOT add duplicate X-Forwarded-Prefix if already present in Vary header', () => { + const response = createRedirectResponse('/home', 302, { + 'Vary': 'X-Forwarded-Prefix, Host', + }); + expect(response.headers.get('Vary')).toBe('X-Forwarded-Prefix, Host'); }); it('should warn if Location header is provided in extra headers in dev mode', () => {