diff --git a/packages/platform-server/src/http.ts b/packages/platform-server/src/http.ts
index d0c2f88060be..26238e190c2e 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,9 +58,23 @@ function relativeUrlsTransformerInterceptorFn(
const baseHref = platformLocation.getBaseHrefFromDOM() || href;
const baseUrl = new URL(baseHref, urlPrefix);
- const newUrl = new URL(request.url, baseUrl).toString();
- return next(request.clone({url: newUrl}));
+ let parsedUrl = new URL(request.url, baseUrl);
+
+ 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 b9de88839d5d..789ef69683d5 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();
+
+ // 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');
+ });
+ });
+ });
+
it('can use HttpInterceptor that injects HttpClient', async () => {
const platform = platformServer([
{provide: INITIAL_CONFIG, useValue: {document: ''}},
@@ -1548,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!');
+ });
+ });
});
});
});