diff --git a/packages/platform-server/src/http.ts b/packages/platform-server/src/http.ts index e6381b1a9f9..0401639829e 100644 --- a/packages/platform-server/src/http.ts +++ b/packages/platform-server/src/http.ts @@ -79,7 +79,10 @@ function relativeUrlsTransformerInterceptorFn( const isProtocolRelative = /^\/\/[^/\\]/.test(trimmedUrl); if (!isProtocolRelative) { // Unrecognized structure that changed origin. Force it to be a local path. - parsedUrl = new URL(trimmedUrl.replace(/^[/\\]+/, '/'), baseUrl); + // A single leading slash is prepended so the value resolves as an absolute path + // and cannot be reinterpreted as an absolute URL, e.g. when the scheme is hidden + // with embedded tabs/newlines (`ht\ttp://attacker.com`) that the URL parser strips. + parsedUrl = new URL('/' + trimmedUrl.replace(/^[/\\]+/, ''), baseUrl); } } diff --git a/packages/platform-server/test/integration_spec.ts b/packages/platform-server/test/integration_spec.ts index 789ef69683d..3c8cd01595c 100644 --- a/packages/platform-server/test/integration_spec.ts +++ b/packages/platform-server/test/integration_spec.ts @@ -1598,6 +1598,21 @@ class HiddenModule {} }); }); + it('should treat scheme-obfuscated SSRF attempts as pathnames', async () => { + // The URL parser strips embedded tabs/newlines, so these resolve to an absolute + // (cross-origin) URL even though the leading characters do not look like a scheme. + const badUrls = ['ht\ttp://attacker.com', 'ht\ntp://attacker.com']; + + ref.injector.get(NgZone).run(() => { + for (const badUrl of badUrls) { + http.get(badUrl).subscribe((body) => { + expect(body).toEqual('success!'); + }); + mock.expectOne('http://localhost:4000/http://attacker.com').flush('success!'); + } + }); + }); + it('should resolve safe path-relative URLs containing backslashes without origin change', async () => { ref.injector.get(NgZone).run(() => { http.get('\\testing').subscribe((body) => {