diff --git a/CHANGELOG.md b/CHANGELOG.md
index dc083e4021f7..1bf6e50b2a2a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,17 @@
+
+
+# 20.3.21 (2026-03-19)
+
+### @angular/ssr
+
+| Commit | Type | Description |
+| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------- |
+| [1dc6992a5](https://github.com/angular/angular-cli/commit/1dc6992a5ae6c5a1f16f22f6c94690d5cf218c38) | fix | disallow x-forwarded-prefix starting with a backslash |
+| [0a2ff0b2b](https://github.com/angular/angular-cli/commit/0a2ff0b2b3aceb228c9447c19fb762df742d7265) | fix | ensure unique values in redirect response Vary header |
+| [cdbac82a8](https://github.com/angular/angular-cli/commit/cdbac82a85b35f24c70a062eeb8a13b521831019) | fix | support custom headers in redirect responses |
+
+
+
# 20.3.20 (2026-03-11)
diff --git a/package.json b/package.json
index dbc82633fb35..496d9947a0f4 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@angular/devkit-repo",
- "version": "20.3.20",
+ "version": "20.3.21",
"private": true,
"description": "Software Development Kit for Angular",
"keywords": [
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..79fb10f424dc
--- /dev/null
+++ b/packages/angular/ssr/src/utils/redirect.ts
@@ -0,0 +1,71 @@
+/**
+ * @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}".`,
+ );
+ }
+
+ // 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);
+ }
+ }
+
+ resHeaders.set('Vary', [...varySet].join(', '));
+ resHeaders.set('Location', location);
+
+ return new Response(null, {
+ status,
+ headers: resHeaders,
+ });
+}
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/redirect_spec.ts b/packages/angular/ssr/test/utils/redirect_spec.ts
new file mode 100644
index 000000000000..b26edd458ac3
--- /dev/null
+++ b/packages/angular/ssr/test/utils/redirect_spec.ts
@@ -0,0 +1,67 @@
+/**
+ * @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('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', () => {
+ // @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/,
+ );
+ });
+ });
+});
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.',
);
}
});