mirror of
https://github.com/angular/angular.git
synced 2026-09-14 13:54:52 +08:00
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:
committed by
Alex Rickabaugh
parent
72696e244e
commit
37e8aadf87
@@ -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!');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user