From 877f017ace4b83277d773aa37f5813e5e9faec7e Mon Sep 17 00:00:00 2001 From: Alan Agius <17563226+alan-agius4@users.noreply.github.com> Date: Mon, 23 Feb 2026 09:41:08 +0000 Subject: [PATCH] fix(@angular/ssr): prevent open redirect via X-Forwarded-Prefix header This change addresses a security vulnerability where `joinUrlParts()` in `packages/angular/ssr/src/utils/url.ts` only stripped one leading slash from URL parts. When the `X-Forwarded-Prefix` header contains multiple leading slashes (e.g., `///evil.com`), the function previously produced a protocol-relative URL (e.g., `//evil.com/home`). If the application issues a redirect (e.g., via a generic redirect route), the browser interprets this 'Location' header as an external redirect to `https://evil.com/home`. This vulnerability poses a significant risk as open redirects can be used in phishing attacks. Additionally, since the redirect response may lack `Cache-Control` headers, intermediate CDNs could cache the poisoned redirect, serving it to other users. This commit fixes the issue by: 1. Updating `joinUrlParts` to internally strip *all* leading and trailing slashes from URL segments, preventing the formation of protocol-relative URLs from malicious input. 2. Adding strict validation for the `X-Forwarded-Prefix` header to immediately reject requests with values starting with multiple slashes (`//`) or backslashes (`\\`). Closes #32501 --- packages/angular/ssr/src/utils/url.ts | 24 ++++--- packages/angular/ssr/src/utils/validation.ts | 12 ++++ packages/angular/ssr/test/utils/url_spec.ts | 12 ++++ .../angular/ssr/test/utils/validation_spec.ts | 67 +++++++++++++++++++ 4 files changed, 106 insertions(+), 9 deletions(-) diff --git a/packages/angular/ssr/src/utils/url.ts b/packages/angular/ssr/src/utils/url.ts index 1fa756e19c19..d5e7c9ac2814 100644 --- a/packages/angular/ssr/src/utils/url.ts +++ b/packages/angular/ssr/src/utils/url.ts @@ -95,26 +95,32 @@ export function addTrailingSlash(url: string): string { * ``` */ export function joinUrlParts(...parts: string[]): string { - const normalizeParts: string[] = []; + const normalizedParts: string[] = []; + for (const part of parts) { if (part === '') { // Skip any empty parts continue; } - let normalizedPart = part; - if (part[0] === '/') { - normalizedPart = normalizedPart.slice(1); + let start = 0; + let end = part.length; + + // Use "Pointers" to avoid intermediate slices + while (start < end && part[start] === '/') { + start++; } - if (part.at(-1) === '/') { - normalizedPart = normalizedPart.slice(0, -1); + + while (end > start && part[end - 1] === '/') { + end--; } - if (normalizedPart !== '') { - normalizeParts.push(normalizedPart); + + if (start < end) { + normalizedParts.push(part.slice(start, end)); } } - return addLeadingSlash(normalizeParts.join('/')); + return addLeadingSlash(normalizedParts.join('/')); } /** diff --git a/packages/angular/ssr/src/utils/validation.ts b/packages/angular/ssr/src/utils/validation.ts index 97ac8606f3b4..c89cdd6a64ed 100644 --- a/packages/angular/ssr/src/utils/validation.ts +++ b/packages/angular/ssr/src/utils/validation.ts @@ -26,6 +26,11 @@ const VALID_PROTO_REGEX = /^https?$/i; */ const VALID_HOST_REGEX = /^[a-z0-9.:-]+$/i; +/** + * Regular expression to validate that the prefix is valid. + */ +const INVALID_PREFIX_REGEX = /^[/\\]{2}|(?:^|[/\\])\.\.?(?:[/\\]|$)/; + /** * Extracts the first value from a multi-value header string. * @@ -253,4 +258,11 @@ function validateHeaders(request: Request): void { if (xForwardedProto && !VALID_PROTO_REGEX.test(xForwardedProto)) { throw new Error('Header "x-forwarded-proto" must be either "http" or "https".'); } + + 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.', + ); + } } diff --git a/packages/angular/ssr/test/utils/url_spec.ts b/packages/angular/ssr/test/utils/url_spec.ts index 9a7a7cb3ad49..a108c7ff1df6 100644 --- a/packages/angular/ssr/test/utils/url_spec.ts +++ b/packages/angular/ssr/test/utils/url_spec.ts @@ -100,6 +100,18 @@ describe('URL Utils', () => { it('should handle an all-empty URL parts', () => { expect(joinUrlParts('', '')).toBe('/'); }); + + it('should normalize parts with multiple leading and trailing slashes', () => { + expect(joinUrlParts('//path//', '///to///', '//resource//')).toBe('/path/to/resource'); + }); + + it('should handle a single part', () => { + expect(joinUrlParts('path')).toBe('/path'); + }); + + it('should handle parts containing only slashes', () => { + expect(joinUrlParts('//', '///')).toBe('/'); + }); }); describe('stripIndexHtmlFromURL', () => { diff --git a/packages/angular/ssr/test/utils/validation_spec.ts b/packages/angular/ssr/test/utils/validation_spec.ts index 99fbf517b60d..10ab896e36f1 100644 --- a/packages/angular/ssr/test/utils/validation_spec.ts +++ b/packages/angular/ssr/test/utils/validation_spec.ts @@ -124,6 +124,73 @@ describe('Validation Utils', () => { 'Header "x-forwarded-host" contains characters that are not allowed.', ); }); + + it('should throw error if x-forwarded-prefix starts with multiple slashes or backslashes', () => { + const inputs = ['//evil', '\\\\evil', '/\\evil', '\\/evil']; + + for (const prefix of inputs) { + const request = new Request('https://example.com', { + headers: { + 'x-forwarded-prefix': prefix, + }, + }); + + expect(() => validateRequest(request, allowedHosts)) + .withContext(`Prefix: "${prefix}"`) + .toThrowError( + 'Header "x-forwarded-prefix" must not start with multiple "/" or "\\" or contain ".", ".." path segments.', + ); + } + }); + + it('should throw error if x-forwarded-prefix contains dot segments', () => { + const inputs = [ + '/./', + '/../', + '/foo/./bar', + '/foo/../bar', + '/.', + '/..', + './', + '../', + '.\\', + '..\\', + '/foo/.\\bar', + '/foo/..\\bar', + '.', + '..', + ]; + + for (const prefix of inputs) { + const request = new Request('https://example.com', { + headers: { + 'x-forwarded-prefix': prefix, + }, + }); + + expect(() => validateRequest(request, allowedHosts)) + .withContext(`Prefix: "${prefix}"`) + .toThrowError( + 'Header "x-forwarded-prefix" must not start with multiple "/" or "\\" or contain ".", ".." path segments.', + ); + } + }); + + it('should validate x-forwarded-prefix with valid dot usage', () => { + const inputs = ['/foo.bar', '/foo.bar/baz', '/v1.2', '/.well-known']; + + for (const prefix of inputs) { + const request = new Request('https://example.com', { + headers: { + 'x-forwarded-prefix': prefix, + }, + }); + + expect(() => validateRequest(request, allowedHosts)) + .withContext(`Prefix: "${prefix}"`) + .not.toThrow(); + } + }); }); describe('cloneRequestAndPatchHeaders', () => {