From f100f2b8867d651dafd60ab4098c386c5be58566 Mon Sep 17 00:00:00 2001 From: Alan Agius <17563226+alan-agius4@users.noreply.github.com> Date: Tue, 31 Mar 2026 07:02:32 +0000 Subject: [PATCH 1/2] fix(@angular/ssr): decode x-forwarded-prefix before validation The `x-forwarded-prefix` header can be percent-encoded. Validating it without decoding can allow bypassing security checks if subsequent processors (such as the `URL` constructor or a browser) implicitly decode it. Key bypass scenarios addressed: - **Implicit Decoding by URL Parsers**: A regex check for a literal `..` might miss `%2e%2e`. However, if the prefix is later passed to a `URL` constructor, it will treat `%2e%2e` as `..`, climbing up a directory. - **Browser Role in Redirects**: If an un-decoded encoded path is sent in a `Location` header, the browser will decode it, leading to unintended navigation. - **Double Slash Bypass**: Checking for a literal `//` misses `%2f%2f`. URL parsers might treat leading double slashes as protocol-relative URLs, leading to Open Redirects if interpreted as a hostname. This change ensures the validation "speaks the same language" as the URL parsing system by decoding the prefix before running safety checks. It also introduces robust handling for malformed percent-encoding. --- packages/angular/ssr/src/utils/validation.ts | 20 ++++++++++++--- .../angular/ssr/test/utils/validation_spec.ts | 25 +++++++++++++++++-- 2 files changed, 39 insertions(+), 6 deletions(-) diff --git a/packages/angular/ssr/src/utils/validation.ts b/packages/angular/ssr/src/utils/validation.ts index e8af64ed9943..7315e919bb52 100644 --- a/packages/angular/ssr/src/utils/validation.ts +++ b/packages/angular/ssr/src/utils/validation.ts @@ -281,9 +281,21 @@ 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 "\\" or multiple "/" or contain ".", ".." path segments.', - ); + if (xForwardedPrefix) { + let xForwardedPrefixDecoded: string; + try { + xForwardedPrefixDecoded = decodeURIComponent(xForwardedPrefix); + } catch (e) { + throw new Error( + 'Header "x-forwarded-prefix" contains an invalid value and cannot be decoded.', + { cause: e }, + ); + } + + if (INVALID_PREFIX_REGEX.test(xForwardedPrefixDecoded)) { + throw new Error( + '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 5c4b6e8cd121..06ad6df66de6 100644 --- a/packages/angular/ssr/test/utils/validation_spec.ts +++ b/packages/angular/ssr/test/utils/validation_spec.ts @@ -154,8 +154,17 @@ describe('Validation Utils', () => { ); }); - it('should throw error if x-forwarded-prefix starts with a backslash or multiple slashes', () => { - const inputs = ['//evil', '\\\\evil', '/\\evil', '\\/evil', '\\evil']; + it('should throw error if x-forwarded-prefix starts with a backslash or multiple slashes including encoded', () => { + const inputs = [ + '//evil', + '\\\\evil', + '/\\evil', + '\\/evil', + '\\evil', + '%5Cevil', + '%2F%2Fevil', + '%2F..%2Fevil', + ]; for (const prefix of inputs) { const request = new Request('https://example.com', { @@ -220,6 +229,18 @@ describe('Validation Utils', () => { .not.toThrow(); } }); + + it('should throw error if x-forwarded-prefix contains malformed encoding', () => { + const request = new Request('https://example.com', { + headers: { + 'x-forwarded-prefix': '/%invalid', + }, + }); + + expect(() => validateRequest(request, allowedHosts, false)).toThrowError( + 'Header "x-forwarded-prefix" contains an invalid value and cannot be decoded.', + ); + }); }); describe('cloneRequestAndPatchHeaders', () => { From 642988a4a7f2ff703e686e757ddb86046935725a Mon Sep 17 00:00:00 2001 From: Alan Agius <17563226+alan-agius4@users.noreply.github.com> Date: Tue, 31 Mar 2026 13:04:07 +0000 Subject: [PATCH 2/2] fixup! fix(@angular/ssr): decode x-forwarded-prefix before validation --- packages/angular/ssr/src/utils/validation.ts | 2 +- packages/angular/ssr/test/utils/validation_spec.ts | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/angular/ssr/src/utils/validation.ts b/packages/angular/ssr/src/utils/validation.ts index 7315e919bb52..474894b1d135 100644 --- a/packages/angular/ssr/src/utils/validation.ts +++ b/packages/angular/ssr/src/utils/validation.ts @@ -284,7 +284,7 @@ function validateHeaders(request: Request): void { if (xForwardedPrefix) { let xForwardedPrefixDecoded: string; try { - xForwardedPrefixDecoded = decodeURIComponent(xForwardedPrefix); + xForwardedPrefixDecoded = decodeURIComponent(xForwardedPrefix).trim(); } catch (e) { throw new Error( 'Header "x-forwarded-prefix" contains an invalid value and cannot be decoded.', diff --git a/packages/angular/ssr/test/utils/validation_spec.ts b/packages/angular/ssr/test/utils/validation_spec.ts index 06ad6df66de6..167ccb721d57 100644 --- a/packages/angular/ssr/test/utils/validation_spec.ts +++ b/packages/angular/ssr/test/utils/validation_spec.ts @@ -197,6 +197,8 @@ describe('Validation Utils', () => { '/foo/..\\bar', '.', '..', + '%2e', + '%2e%2e/foo', ]; for (const prefix of inputs) {