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.

(cherry picked from commit 140c4d04cb)
This commit is contained in:
Matthieu Riegler
2026-05-27 19:23:29 +02:00
committed by Alex Rickabaugh
parent 72696e244e
commit 37e8aadf87
2 changed files with 133 additions and 3 deletions
+26 -3
View File
@@ -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';
@@ -41,10 +41,21 @@ export class ServerXhr implements XhrFactory {
}
}
/**
* Regex to match a URL schema.
*/
const URL_SCHEMA_REGEXP = /^(?:[a-zA-Z][a-zA-Z0-9+\-.]*:)/;
function relativeUrlsTransformerInterceptorFn(
request: HttpRequest<unknown>,
next: HttpHandlerFn,
): Observable<HttpEvent<unknown>> {
const trimmedUrl = request.url.trim();
if (URL_SCHEMA_REGEXP.test(trimmedUrl)) {
// URLs with a schema should be left unchanged.
return next(request);
}
const platformLocation = inject(PlatformLocation);
const {href, protocol, hostname, port} = platformLocation;
if (!protocol.startsWith('http')) {
@@ -58,9 +69,21 @@ 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 isProtocolRelative = /^\/\/[^/\\]/.test(trimmedUrl);
if (!isProtocolRelative) {
// Unrecognized structure that changed origin. Force it to be a local path.
parsedUrl = new URL(trimmedUrl.replace(/^[/\\]+/, '/'), baseUrl);
}
}
return next(request.clone({url: parsedUrl.toString()}));
}
export const SERVER_HTTP_PROVIDERS: Provider[] = [
@@ -1455,6 +1455,26 @@ class HiddenModule {}
});
});
it('prevents SSRF bypasses via backslash URLs in HttpClient', async () => {
const platform = platformServer([
{
provide: INITIAL_CONFIG,
useValue: {document: '<app></app>', 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: '<app></app>'}},
@@ -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<HttpInterceptorExampleModule>;
let http: HttpClient;
beforeEach(async () => {
const platform = platformServer([
{
provide: INITIAL_CONFIG,
useValue: {
document: '<app></app>',
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!');
});
});
});
});
});