From 33f1ef566995aea387000f04158e7aa114f7b0eb Mon Sep 17 00:00:00 2001 From: Alan Agius <17563226+alan-agius4@users.noreply.github.com> Date: Tue, 31 Mar 2026 13:01:24 +0000 Subject: [PATCH] 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 9e83e144b347..49b27428df1d 100644 --- a/packages/angular/ssr/src/utils/validation.ts +++ b/packages/angular/ssr/src/utils/validation.ts @@ -260,9 +260,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).trim(); + } 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 acf1e4829e8e..595ed397a751 100644 --- a/packages/angular/ssr/test/utils/validation_spec.ts +++ b/packages/angular/ssr/test/utils/validation_spec.ts @@ -125,8 +125,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', { @@ -191,6 +200,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)).toThrowError( + 'Header "x-forwarded-prefix" contains an invalid value and cannot be decoded.', + ); + }); }); describe('cloneRequestAndPatchHeaders', () => {