Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions packages/angular/ssr/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -199,6 +199,7 @@ export class AngularServerApp {
buildPathWithParams(redirectTo, url.pathname),
),
status,
headers,
);
}

Expand Down Expand Up @@ -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) {
Expand Down
28 changes: 24 additions & 4 deletions packages/angular/ssr/src/utils/redirect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,20 +27,40 @@ 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<string, string>,
): 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: {
'Location': location,
},
headers: resHeaders,
});
}
4 changes: 2 additions & 2 deletions packages/angular/ssr/src/utils/validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.',
);
}
}
60 changes: 60 additions & 0 deletions packages/angular/ssr/test/utils/redirect_spec.ts
Original file line number Diff line number Diff line change
@@ -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/,
);
});
});
});
8 changes: 4 additions & 4 deletions packages/angular/ssr/test/utils/validation_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', {
Expand All @@ -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.',
);
}
});
Expand Down Expand Up @@ -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.',
);
}
});
Expand Down
Loading