From 1a5d65d254a1197525fb5b9996ba1a4d54a89423 Mon Sep 17 00:00:00 2001 From: Matthieu Riegler Date: Thu, 21 May 2026 00:58:23 +0200 Subject: [PATCH 1/2] fix(platform-server): prevent SSRF bypasses via backslash URLs in HttpClient Encoding backslashes ensures that they are not normalized to slashes and where they could generate a protocol relative URL. --- packages/platform-server/src/http.ts | 10 ++++++++-- .../platform-server/test/integration_spec.ts | 20 +++++++++++++++++++ 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/packages/platform-server/src/http.ts b/packages/platform-server/src/http.ts index d0c2f88060be..9b78299e5c90 100644 --- a/packages/platform-server/src/http.ts +++ b/packages/platform-server/src/http.ts @@ -8,10 +8,10 @@ import {PlatformLocation, XhrFactory} from '@angular/common'; import { + ɵHTTP_ROOT_INTERCEPTOR_FNS as HTTP_ROOT_INTERCEPTOR_FNS, HttpEvent, HttpHandlerFn, HttpRequest, - ɵHTTP_ROOT_INTERCEPTOR_FNS as HTTP_ROOT_INTERCEPTOR_FNS, } from '@angular/common/http'; import {inject, Injectable, Provider} from '@angular/core'; import {Observable} from 'rxjs'; @@ -58,7 +58,13 @@ function relativeUrlsTransformerInterceptorFn( const baseHref = platformLocation.getBaseHrefFromDOM() || href; const baseUrl = new URL(baseHref, urlPrefix); - const newUrl = new URL(request.url, baseUrl).toString(); + + // Prevent SSRF bypasses via backslash URLs. + // The `URL` constructor normalizes backslashes to forward slashes, which can + // cause `/\` to be evaluated as a protocol-relative URL (`//`). + // Replacing backslashes with their URL-encoded equivalent prevents this. + const safeUrl = request.url.replace(/\\/g, '%5C'); + const newUrl = new URL(safeUrl, baseUrl).toString(); return next(request.clone({url: newUrl})); } diff --git a/packages/platform-server/test/integration_spec.ts b/packages/platform-server/test/integration_spec.ts index b9de88839d5d..00c7b3557738 100644 --- a/packages/platform-server/test/integration_spec.ts +++ b/packages/platform-server/test/integration_spec.ts @@ -1455,6 +1455,26 @@ class HiddenModule {} }); }); + it('prevents SSRF bypasses via backslash URLs in HttpClient', async () => { + const platform = platformServer([ + { + provide: INITIAL_CONFIG, + useValue: {document: '', url: 'http://localhost:4000/base'}, + }, + ]); + await platform.bootstrapModule(HttpClientExampleModule).then((ref) => { + const mock = ref.injector.get(HttpTestingController); + const http = ref.injector.get(HttpClient); + ref.injector.get(NgZone).run(() => { + http.get('/\\evil.com/api').subscribe(); + + // The URL constructor normalizes backslashes to %5C + // PREVENTING it from hitting http://evil.com/api via a protocol-relative URL interpretation. + mock.expectOne('http://localhost:4000/%5Cevil.com/api').flush('safe'); + }); + }); + }); + it('can use HttpInterceptor that injects HttpClient', async () => { const platform = platformServer([ {provide: INITIAL_CONFIG, useValue: {document: ''}}, From 6611e71d8302ef749f34009b99addd283d807659 Mon Sep 17 00:00:00 2001 From: Matthieu Riegler Date: Thu, 21 May 2026 12:32:35 +0200 Subject: [PATCH 2/2] fixup! fix(platform-server): prevent SSRF bypasses via backslash URLs in HttpClient --- packages/platform-server/src/http.ts | 22 +++-- .../platform-server/test/integration_spec.ts | 93 ++++++++++++++++++- 2 files changed, 105 insertions(+), 10 deletions(-) diff --git a/packages/platform-server/src/http.ts b/packages/platform-server/src/http.ts index 9b78299e5c90..26238e190c2e 100644 --- a/packages/platform-server/src/http.ts +++ b/packages/platform-server/src/http.ts @@ -59,14 +59,22 @@ function relativeUrlsTransformerInterceptorFn( const baseHref = platformLocation.getBaseHrefFromDOM() || href; const baseUrl = new URL(baseHref, urlPrefix); - // Prevent SSRF bypasses via backslash URLs. - // The `URL` constructor normalizes backslashes to forward slashes, which can - // cause `/\` to be evaluated as a protocol-relative URL (`//`). - // Replacing backslashes with their URL-encoded equivalent prevents this. - const safeUrl = request.url.replace(/\\/g, '%5C'); - const newUrl = new URL(safeUrl, baseUrl).toString(); + let parsedUrl = new URL(request.url, baseUrl); - return next(request.clone({url: newUrl})); + if (parsedUrl.origin !== baseUrl.origin) { + // If the request changed the origin, we check if it was authorized to do so. + // Legitimate absolute URLs start with a scheme (e.g. http://) or are protocol-relative (//). + // SSRF bypasses via backslashes (e.g. `/\attacker.com`, `\\attacker.com`) evade naive checks. + const isAbsolute = /^[\s\r\n]*(?:[a-zA-Z][a-zA-Z0-9+\-.]*:)/.test(request.url); + const isProtocolRelative = /^[\s\r\n]*\/\/[^/\\]/.test(request.url); + + if (!isAbsolute && !isProtocolRelative) { + // Unrecognized structure that changed origin. Force it to be a local path. + parsedUrl = new URL(request.url.replace(/^[\s\r\n]*[/\\]+/, '/'), baseUrl); + } + } + + return next(request.clone({url: parsedUrl.toString()})); } export const SERVER_HTTP_PROVIDERS: Provider[] = [ diff --git a/packages/platform-server/test/integration_spec.ts b/packages/platform-server/test/integration_spec.ts index 00c7b3557738..789ef69683d5 100644 --- a/packages/platform-server/test/integration_spec.ts +++ b/packages/platform-server/test/integration_spec.ts @@ -1468,9 +1468,9 @@ class HiddenModule {} ref.injector.get(NgZone).run(() => { http.get('/\\evil.com/api').subscribe(); - // The URL constructor normalizes backslashes to %5C - // PREVENTING it from hitting http://evil.com/api via a protocol-relative URL interpretation. - mock.expectOne('http://localhost:4000/%5Cevil.com/api').flush('safe'); + // To prevent SSRF, we ensures it's forced as a relative path and backslashes + // inside path-relative segments are normalized via URL constructor, generating a safe URL. + mock.expectOne('http://localhost:4000/evil.com/api').flush('safe'); }); }); }); @@ -1568,6 +1568,93 @@ class HiddenModule {} mock.expectOne('http://localhost/testing').flush('success!'); }); }); + + it('should allow legitimate protocol-relative URLs', async () => { + ref.injector.get(NgZone).run(() => { + http.get('//example.com/testing').subscribe((body) => { + expect(body).toEqual('success!'); + }); + mock.expectOne('http://example.com/testing').flush('success!'); + }); + }); + + it('should treat backslash bypass SSRF attempts in relative requests strictly as pathnames', async () => { + const badUrls = [ + '/\\attacker.com', + '\\\\attacker.com', + '///attacker.com', + '//\\attacker.com', + ' /\\attacker.com', + '\r\n/\\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/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) => { + expect(body).toEqual('success!'); + }); + mock.expectOne('http://localhost:4000/testing').flush('success!'); + }); + }); + + it('should resolve backslashes inside path-relative segments without origin change', async () => { + ref.injector.get(NgZone).run(() => { + http.get('/foo\\bar').subscribe((body) => { + expect(body).toEqual('success!'); + }); + mock.expectOne('http://localhost:4000/foo/bar').flush('success!'); + }); + }); + + it('should resolve relative request URLs without leading slash relative to parent path', async () => { + ref.injector.get(NgZone).run(() => { + http.get('testing').subscribe((body) => { + expect(body).toEqual('success!'); + }); + mock.expectOne('http://localhost:4000/testing').flush('success!'); + }); + }); + }); + + describe(`given 'url' is provided in 'INITIAL_CONFIG' with a trailing slash`, () => { + let mock: HttpTestingController; + let ref: NgModuleRef; + let http: HttpClient; + + beforeEach(async () => { + const platform = platformServer([ + { + provide: INITIAL_CONFIG, + useValue: { + document: '', + url: 'http://localhost:4000/foo/', + }, + }, + ]); + + ref = await platform.bootstrapModule(HttpInterceptorExampleModule); + mock = ref.injector.get(HttpTestingController); + http = ref.injector.get(HttpClient); + }); + + it('should resolve sub-path relative request URLs relative to trailing-slash base URL', async () => { + ref.injector.get(NgZone).run(() => { + http.get('testing').subscribe((body) => { + expect(body).toEqual('success!'); + }); + mock.expectOne('http://localhost:4000/foo/testing').flush('success!'); + }); + }); }); }); });