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/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/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/, + ); + }); + }); +}); 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.', ); } });