From 3e924cc8dbbb57f23b262cb8f0d7e2bd0673034c Mon Sep 17 00:00:00 2001 From: Alan Agius Date: Tue, 25 Aug 2026 11:27:58 +0000 Subject: [PATCH] fix(platform-server): avoid stripping unicode whitespace during url resolution Avoid trimming urlStr with String.prototype.trim() in resolveUrl to ensure URL parsing and resolution align with the WHATWG URL standard. --- packages/platform-server/src/url.ts | 2 -- .../platform-server/test/integration_spec.ts | 11 +++++++++++ packages/platform-server/test/url_spec.ts | 17 +++++++++++++++++ 3 files changed, 28 insertions(+), 2 deletions(-) diff --git a/packages/platform-server/src/url.ts b/packages/platform-server/src/url.ts index 8dad4ac18c8..d0639980db8 100644 --- a/packages/platform-server/src/url.ts +++ b/packages/platform-server/src/url.ts @@ -57,8 +57,6 @@ export function resolveUrl( return originUrl || null; } - urlStr = urlStr.trim(); - // Fast-path: if the URL is a valid, standard absolute URL, parse and return it immediately. let resolved: URL | undefined; try { diff --git a/packages/platform-server/test/integration_spec.ts b/packages/platform-server/test/integration_spec.ts index ace92bf8ca2..bd7a7b33736 100644 --- a/packages/platform-server/test/integration_spec.ts +++ b/packages/platform-server/test/integration_spec.ts @@ -1582,6 +1582,17 @@ class HiddenModule {} }); }); + it('should resolve non-breaking space prefixed URLs as relative paths on the same origin', async () => { + ref.injector.get(NgZone).run(() => { + http.get('\u00A0//attacker.example/collect').subscribe((body) => { + expect(body).toEqual('success!'); + }); + mock + .expectOne('http://localhost:4000/%C2%A0//attacker.example/collect') + .flush('success!'); + }); + }); + it('should reject backslash bypass SSRF attempts in relative requests and throw a suspicious origin error', async () => { const badUrls = [ '/\\attacker.com', diff --git a/packages/platform-server/test/url_spec.ts b/packages/platform-server/test/url_spec.ts index 387f011a8df..cbd9f7e6370 100644 --- a/packages/platform-server/test/url_spec.ts +++ b/packages/platform-server/test/url_spec.ts @@ -68,6 +68,22 @@ describe('resolveUrl', () => { const url = 'ht\ntp://evil.com/path'; expect(() => resolveUrl(url, 'http://test.com')).toThrowError(/NG05703/); }); + + it('should not trim unicode whitespace into protocol-relative URLs', () => { + const urls = ['\u00A0//attacker.example/collect', '\uFEFF//attacker.example/collect']; + + for (const urlStr of urls) { + const urlWithProtocolRelative = resolveUrl(urlStr, 'http://test.com', { + allowProtocolRelative: true, + }); + expect(urlWithProtocolRelative.origin).toBe('http://test.com'); + expect(urlWithProtocolRelative.pathname).toContain('//attacker.example/collect'); + + const urlWithoutProtocolRelative = resolveUrl(urlStr, 'http://test.com'); + expect(urlWithoutProtocolRelative.origin).toBe('http://test.com'); + expect(urlWithoutProtocolRelative.pathname).toContain('//attacker.example/collect'); + } + }); }); describe('without origin', () => { @@ -76,6 +92,7 @@ describe('resolveUrl', () => { expect(resolveUrl('deep/path')).toBeNull(); expect(resolveUrl('/\\attacker.com/deep/path')).toBeNull(); expect(resolveUrl('\\\\attacker.com/deep/path')).toBeNull(); + expect(resolveUrl('\u00A0//attacker.com/deep/path')).toBeNull(); }); it('should parse valid absolute URLs', () => {