mirror of
https://github.com/angular/angular.git
synced 2026-09-14 13:54:52 +08:00
fix(platform-server): prevent SSRF bypasses via protocol-relative and backslash URLs
The `parseUrl` function in `ServerPlatformLocation` uses `new URL(urlStr, origin)` to parse incoming request URLs during SSR. Per the WHATWG URL specification, protocol-relative URLs (`//evil.com`) and backslash-prefixed URLs (`/\evil.com`) can override the hostname component of the base URL. This vulnerability typically manifests in SSR setups (e.g., Express) where `req.url` is passed directly to `renderApplication` or `renderModule`: ```typescript // Example usage in an Express server handling: http://localhost:4000//evil.com app.get('*', async (req, res) => { const html = await renderApplication(bootstrap, { document: template, url: req.url, // req.url is "//evil.com" }); res.send(html); }); ```
This commit is contained in:
committed by
Kirill Cherkashin
parent
3eba900d3f
commit
ede7c58a2a
@@ -18,29 +18,18 @@ import {Subject} from 'rxjs';
|
||||
|
||||
import {INITIAL_CONFIG} from './tokens';
|
||||
|
||||
function parseUrl(
|
||||
urlStr: string,
|
||||
origin: string,
|
||||
): {
|
||||
hostname: string;
|
||||
protocol: string;
|
||||
port: string;
|
||||
pathname: string;
|
||||
search: string;
|
||||
hash: string;
|
||||
href: string;
|
||||
} {
|
||||
const {hostname, protocol, port, pathname, search, hash, href} = new URL(urlStr, origin);
|
||||
/**
|
||||
* Parses a URL string and returns a URL object.
|
||||
* @param urlStr The string to parse.
|
||||
* @param origin The origin to use for resolving the URL.
|
||||
* @returns The parsed URL.
|
||||
*/
|
||||
function parseUrl(urlStr: string, origin: string): URL {
|
||||
// If the URL is empty or start with a `/` it is a pathname relative to the origin
|
||||
// otherwise it's an absolute URL.
|
||||
const urlToParse = urlStr.length === 0 || urlStr[0] === '/' ? origin + urlStr : urlStr;
|
||||
|
||||
return {
|
||||
hostname,
|
||||
href,
|
||||
protocol,
|
||||
port,
|
||||
pathname,
|
||||
search,
|
||||
hash,
|
||||
};
|
||||
return new URL(urlToParse);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -65,14 +54,17 @@ export class ServerPlatformLocation implements PlatformLocation {
|
||||
return;
|
||||
}
|
||||
if (config.url) {
|
||||
const url = parseUrl(config.url, this._doc.location.origin);
|
||||
this.protocol = url.protocol;
|
||||
this.hostname = url.hostname;
|
||||
this.port = url.port;
|
||||
this.pathname = url.pathname;
|
||||
this.search = url.search;
|
||||
this.hash = url.hash;
|
||||
this.href = url.href;
|
||||
const {protocol, hostname, port, pathname, search, hash, href} = parseUrl(
|
||||
config.url,
|
||||
this._doc.location.origin,
|
||||
);
|
||||
this.protocol = protocol;
|
||||
this.hostname = hostname;
|
||||
this.port = port;
|
||||
this.pathname = pathname;
|
||||
this.search = search;
|
||||
this.hash = hash;
|
||||
this.href = href;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,12 +106,13 @@ export class ServerPlatformLocation implements PlatformLocation {
|
||||
|
||||
replaceState(state: any, title: string, newUrl: string): void {
|
||||
const oldUrl = this.url;
|
||||
const parsedUrl = parseUrl(newUrl, this._doc.location.origin);
|
||||
(this as Writable<this>).pathname = parsedUrl.pathname;
|
||||
(this as Writable<this>).search = parsedUrl.search;
|
||||
(this as Writable<this>).href = parsedUrl.href;
|
||||
(this as Writable<this>).protocol = parsedUrl.protocol;
|
||||
this.setHash(parsedUrl.hash, oldUrl);
|
||||
const {pathname, search, hash, href, protocol} = parseUrl(newUrl, this._doc.location.origin);
|
||||
const writableThis = this as Writable<this>;
|
||||
writableThis.pathname = pathname;
|
||||
writableThis.search = search;
|
||||
writableThis.href = href;
|
||||
writableThis.protocol = protocol;
|
||||
this.setHash(hash, oldUrl);
|
||||
}
|
||||
|
||||
pushState(state: any, title: string, newUrl: string): void {
|
||||
|
||||
@@ -8,20 +8,13 @@
|
||||
import '@angular/compiler';
|
||||
|
||||
import {PlatformLocation, ɵgetDOM as getDOM} from '@angular/common';
|
||||
import {Component, destroyPlatform} from '@angular/core';
|
||||
import {destroyPlatform} from '@angular/core';
|
||||
import {INITIAL_CONFIG, platformServer} from '@angular/platform-server';
|
||||
import {bootstrapApplication} from '@angular/platform-browser';
|
||||
|
||||
(function () {
|
||||
if (getDOM().supportsDOMEvents) return; // NODE only
|
||||
|
||||
describe('PlatformLocation', () => {
|
||||
@Component({
|
||||
selector: 'app',
|
||||
template: `Works!`,
|
||||
})
|
||||
class LocationApp {}
|
||||
|
||||
beforeEach(() => {
|
||||
destroyPlatform();
|
||||
});
|
||||
@@ -34,15 +27,8 @@ import {bootstrapApplication} from '@angular/platform-browser';
|
||||
const platform = platformServer([
|
||||
{provide: INITIAL_CONFIG, useValue: {document: '<app></app>'}},
|
||||
]);
|
||||
const appRef = await bootstrapApplication(
|
||||
LocationApp,
|
||||
{
|
||||
providers: [{provide: INITIAL_CONFIG, useValue: {document: '<app></app>'}}],
|
||||
},
|
||||
{platformRef: platform},
|
||||
);
|
||||
|
||||
const location = appRef.injector.get(PlatformLocation);
|
||||
const location = platform.injector.get(PlatformLocation);
|
||||
expect(location.pathname).toBe('/');
|
||||
platform.destroy();
|
||||
});
|
||||
@@ -57,23 +43,7 @@ import {bootstrapApplication} from '@angular/platform-browser';
|
||||
},
|
||||
]);
|
||||
|
||||
const appRef = await bootstrapApplication(
|
||||
LocationApp,
|
||||
{
|
||||
providers: [
|
||||
{
|
||||
provide: INITIAL_CONFIG,
|
||||
useValue: {
|
||||
document: '<app></app>',
|
||||
url: 'http://test.com/deep/path?query#hash',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{platformRef: platform},
|
||||
);
|
||||
|
||||
const location = appRef.injector.get(PlatformLocation);
|
||||
const location = platform.injector.get(PlatformLocation);
|
||||
expect(location.pathname).toBe('/deep/path');
|
||||
expect(location.search).toBe('?query');
|
||||
expect(location.hash).toBe('#hash');
|
||||
@@ -90,23 +60,7 @@ import {bootstrapApplication} from '@angular/platform-browser';
|
||||
},
|
||||
]);
|
||||
|
||||
const appRef = await bootstrapApplication(
|
||||
LocationApp,
|
||||
{
|
||||
providers: [
|
||||
{
|
||||
provide: INITIAL_CONFIG,
|
||||
useValue: {
|
||||
document: '<app></app>',
|
||||
url: 'http://test.com:80/deep/path?query#hash',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{platformRef: platform},
|
||||
);
|
||||
|
||||
const location = appRef.injector.get(PlatformLocation);
|
||||
const location = platform.injector.get(PlatformLocation);
|
||||
expect(location.hostname).toBe('test.com');
|
||||
expect(location.protocol).toBe('http:');
|
||||
expect(location.port).toBe('');
|
||||
@@ -126,23 +80,7 @@ import {bootstrapApplication} from '@angular/platform-browser';
|
||||
},
|
||||
]);
|
||||
|
||||
const appRef = await bootstrapApplication(
|
||||
LocationApp,
|
||||
{
|
||||
providers: [
|
||||
{
|
||||
provide: INITIAL_CONFIG,
|
||||
useValue: {
|
||||
document: '<app></app>',
|
||||
url: 'http://test.com/deep/path',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{platformRef: platform},
|
||||
);
|
||||
|
||||
const location = appRef.injector.get(PlatformLocation);
|
||||
const location = platform.injector.get(PlatformLocation);
|
||||
expect(location.pathname).toBe('/deep/path');
|
||||
expect(location.search).toBe('');
|
||||
expect(location.hash).toBe('');
|
||||
@@ -153,14 +91,7 @@ import {bootstrapApplication} from '@angular/platform-browser';
|
||||
{provide: INITIAL_CONFIG, useValue: {document: '<app></app>'}},
|
||||
]);
|
||||
|
||||
const appRef = await bootstrapApplication(
|
||||
LocationApp,
|
||||
{
|
||||
providers: [{provide: INITIAL_CONFIG, useValue: {document: '<app></app>'}}],
|
||||
},
|
||||
{platformRef: platform},
|
||||
);
|
||||
const location = appRef.injector.get(PlatformLocation);
|
||||
const location = platform.injector.get(PlatformLocation);
|
||||
location.pushState(null, 'Test', '/foo#bar');
|
||||
expect(location.pathname).toBe('/foo');
|
||||
expect(location.hash).toBe('#bar');
|
||||
@@ -178,22 +109,7 @@ import {bootstrapApplication} from '@angular/platform-browser';
|
||||
},
|
||||
]);
|
||||
|
||||
const appRef = await bootstrapApplication(
|
||||
LocationApp,
|
||||
{
|
||||
providers: [
|
||||
{
|
||||
provide: INITIAL_CONFIG,
|
||||
useValue: {
|
||||
document: '<app></app>',
|
||||
url: 'http://test.com/deep/path?query#hash',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{platformRef: platform},
|
||||
);
|
||||
const location = appRef.injector.get(PlatformLocation);
|
||||
const location = platform.injector.get(PlatformLocation);
|
||||
location.replaceState(null, 'Test', '/foo#bar');
|
||||
expect(location.pathname).toBe('/foo');
|
||||
expect(location.hash).toBe('#bar');
|
||||
@@ -206,24 +122,42 @@ import {bootstrapApplication} from '@angular/platform-browser';
|
||||
const platform = platformServer([
|
||||
{provide: INITIAL_CONFIG, useValue: {document: '<app></app>'}},
|
||||
]);
|
||||
bootstrapApplication(
|
||||
LocationApp,
|
||||
{
|
||||
providers: [{provide: INITIAL_CONFIG, useValue: {document: '<app></app>'}}],
|
||||
},
|
||||
{platformRef: platform},
|
||||
).then((appRef) => {
|
||||
const location: PlatformLocation = appRef.injector.get(PlatformLocation);
|
||||
expect(location.pathname).toBe('/');
|
||||
location.onHashChange((e: any) => {
|
||||
expect(e.type).toBe('hashchange');
|
||||
expect(e.oldUrl).toBe('/');
|
||||
expect(e.newUrl).toBe('/foo#bar');
|
||||
platform.destroy();
|
||||
done();
|
||||
});
|
||||
location.pushState(null, 'Test', '/foo#bar');
|
||||
const location = platform.injector.get(PlatformLocation);
|
||||
|
||||
expect(location.pathname).toBe('/');
|
||||
location.onHashChange((e: any) => {
|
||||
expect(e.type).toBe('hashchange');
|
||||
expect(e.oldUrl).toBe('/');
|
||||
expect(e.newUrl).toBe('/foo#bar');
|
||||
platform.destroy();
|
||||
done();
|
||||
});
|
||||
location.pushState(null, 'Test', '/foo#bar');
|
||||
});
|
||||
|
||||
it('neutralizes hostname hijack attempts', async () => {
|
||||
const urls = ['/\\attacker.com/deep/path', '//attacker.com/deep/path'];
|
||||
|
||||
for (const url of urls) {
|
||||
const platform = platformServer([
|
||||
{
|
||||
provide: INITIAL_CONFIG,
|
||||
useValue: {
|
||||
document: '',
|
||||
// This should be treated as relative URL.
|
||||
// Example: `req.url: '//attacker.com/deep/path'` where request
|
||||
// to express server is 'http://localhost:4200//attacker.com/deep/path'.
|
||||
url,
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const location = platform.injector.get(PlatformLocation);
|
||||
platform.destroy();
|
||||
|
||||
expect(location.hostname).withContext(`hostname for URL: "${url}"`).toBe('');
|
||||
expect(location.pathname).withContext(`pathname for URL: "${url}"`).toBe(url);
|
||||
}
|
||||
});
|
||||
});
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user