mirror of
https://github.com/angular/angular.git
synced 2026-09-14 13:54:52 +08:00
fix(platform-server): secure location and document initialization against SSRF and path hijack
Normalizes the URL and path parsing logic inside platform-server by consolidating security checks and normalizations into a single, unified parseUrl helper function. This includes: - Collapsing multiple consecutive leading slashes and backslashes (e.g., // or /\) to a single forward slash to avoid protocol-relative parsing of path-like & relative inputs. - Rejecting malformed absolute URLs that are otherwise accepted by lenient DOM parsers like Domino but rejected by standard WHATWG parsers, preventing SSRF / allowedHosts validation bypasses. - Ensuring parseDocument gets the fully parsed and normalized URL instead of raw, unvalidated configuration values, preventing virtual document hostname adoption/origin hijack. - Moving parseUrl unit tests into a dedicated url_spec.ts test file to keep platform_location_spec.ts clean and decoupled.
This commit is contained in:
committed by
Alex Rickabaugh
parent
b56e865148
commit
1307ff355c
@@ -17,24 +17,7 @@ import {inject, Injectable, ɵWritable as Writable} from '@angular/core';
|
||||
import {Subject} from 'rxjs';
|
||||
|
||||
import {INITIAL_CONFIG} from './tokens';
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export function parseUrl(urlStr: string, origin: string): URL {
|
||||
if (URL.canParse(urlStr)) {
|
||||
return new URL(urlStr);
|
||||
}
|
||||
|
||||
if (urlStr && urlStr[0] !== '/') {
|
||||
urlStr = `/${urlStr}`;
|
||||
}
|
||||
|
||||
return new URL(origin + urlStr);
|
||||
}
|
||||
import {parseUrl} from './url';
|
||||
|
||||
/**
|
||||
* Server-side implementation of URL state. Implements `pathname`, `search`, and `hash`
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
|
||||
import {DominoAdapter, parseDocument} from './domino_adapter';
|
||||
import {SERVER_HTTP_PROVIDERS} from './http';
|
||||
import {parseUrl} from './url';
|
||||
import {ServerPlatformLocation} from './location';
|
||||
import {enableDomEmulation, PlatformState} from './platform_state';
|
||||
import {ServerEventManagerPlugin} from './server_events';
|
||||
@@ -100,7 +101,10 @@ function _document() {
|
||||
document =
|
||||
typeof config.document === 'string'
|
||||
? _enableDomEmulation
|
||||
? parseDocument(config.document, config.url)
|
||||
? parseDocument(
|
||||
config.document,
|
||||
config.url !== undefined ? parseUrl(config.url, 'http://localhost').href : undefined,
|
||||
)
|
||||
: window.document
|
||||
: config.document;
|
||||
} else {
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright Google LLC All Rights Reserved.
|
||||
*
|
||||
* Use of this source code is governed by an MIT-style license that can be
|
||||
* found in the LICENSE file at https://angular.dev/license
|
||||
*/
|
||||
|
||||
const LEADING_SLASHES_REGEX = /^[/\\]+/;
|
||||
|
||||
/**
|
||||
* Parses a URL string and returns a resolved WHATWG URL object.
|
||||
* If no origin is provided, it parses and returns the URL only if it is a valid absolute URL;
|
||||
* otherwise it returns `null` (or throws if the URL is a malformed absolute URL).
|
||||
* If an origin is provided, relative URLs and protocol-relative URLs are normalized and resolved against it.
|
||||
*/
|
||||
export function parseUrl(urlStr: string | undefined): URL | null;
|
||||
export function parseUrl(urlStr: string | undefined, origin: string): URL;
|
||||
export function parseUrl(urlStr: string | undefined, origin?: string): URL | null {
|
||||
if (!urlStr) {
|
||||
return origin !== undefined ? new URL('/', origin) : null;
|
||||
}
|
||||
|
||||
if (URL.canParse(urlStr)) {
|
||||
return new URL(urlStr);
|
||||
}
|
||||
|
||||
if (/^[a-zA-Z][a-zA-Z0-9+.-]*:(\/\/|\\\\)/.test(urlStr)) {
|
||||
throw new Error(`Invalid URL: ${urlStr}`);
|
||||
}
|
||||
|
||||
if (origin === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let normalizedPath = urlStr.replace(LEADING_SLASHES_REGEX, '/');
|
||||
if (normalizedPath[0] !== '/') {
|
||||
normalizedPath = `/${normalizedPath}`;
|
||||
}
|
||||
|
||||
return new URL(normalizedPath, origin);
|
||||
}
|
||||
@@ -29,6 +29,7 @@ import {platformServer} from './server';
|
||||
import {PlatformState} from './platform_state';
|
||||
import {BEFORE_APP_SERIALIZED, INITIAL_CONFIG, PlatformConfig} from './tokens';
|
||||
import {createScript} from './transfer_state';
|
||||
import {parseUrl} from './url';
|
||||
|
||||
/**
|
||||
* Event dispatch (JSAction) script is inlined into the HTML by the build
|
||||
@@ -377,11 +378,14 @@ export async function renderApplication(
|
||||
}
|
||||
|
||||
function validateAllowedHosts(url: string | undefined, allowedHosts: string[] | undefined) {
|
||||
if (typeof url === 'string' && URL.canParse(url)) {
|
||||
const hostname = new URL(url).hostname;
|
||||
const allowedHostsSet: ReadonlySet<string> = new Set(allowedHosts);
|
||||
if (!isHostAllowed(hostname, allowedHostsSet)) {
|
||||
throw new Error(`Host ${url} is not allowed. You can configure \`allowedHosts\` option.`);
|
||||
if (typeof url === 'string') {
|
||||
const parsedUrl = parseUrl(url);
|
||||
if (parsedUrl !== null) {
|
||||
const hostname = parsedUrl.hostname;
|
||||
const allowedHostsSet: ReadonlySet<string> = new Set(allowedHosts);
|
||||
if (!isHostAllowed(hostname, allowedHostsSet)) {
|
||||
throw new Error(`Host ${url} is not allowed. You can configure \`allowedHosts\` option.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,30 +7,13 @@
|
||||
*/
|
||||
import '@angular/compiler';
|
||||
|
||||
import {PlatformLocation, ɵgetDOM as getDOM} from '@angular/common';
|
||||
import {DOCUMENT, PlatformLocation, ɵgetDOM as getDOM} from '@angular/common';
|
||||
import {destroyPlatform} from '@angular/core';
|
||||
import {INITIAL_CONFIG, platformServer} from '@angular/platform-server';
|
||||
|
||||
import {parseUrl} from '../src/location';
|
||||
|
||||
(function () {
|
||||
if (getDOM().supportsDOMEvents) return; // NODE only
|
||||
|
||||
describe('parseUrl', () => {
|
||||
it('should resolve relative paths against origin', () => {
|
||||
const url = parseUrl('/deep/path?query#hash', 'http://test.com');
|
||||
expect(url.href).toBe('http://test.com/deep/path?query#hash');
|
||||
expect(url.search).toBe('?query');
|
||||
expect(url.hash).toBe('#hash');
|
||||
});
|
||||
|
||||
it('should resolve absolute URLs ignoring origin', () => {
|
||||
const url = parseUrl('http://other.com/deep/path', 'http://test.com');
|
||||
expect(url.href).toBe('http://other.com/deep/path');
|
||||
expect(url.origin).toBe('http://other.com');
|
||||
});
|
||||
});
|
||||
|
||||
describe('PlatformLocation', () => {
|
||||
beforeEach(() => {
|
||||
destroyPlatform();
|
||||
@@ -173,7 +156,72 @@ import {parseUrl} from '../src/location';
|
||||
platform.destroy();
|
||||
|
||||
expect(location.hostname).withContext(`hostname for URL: "${url}"`).toBe('');
|
||||
expect(location.pathname).withContext(`pathname for URL: "${url}"`).toBe(url);
|
||||
expect(location.pathname)
|
||||
.withContext(`pathname for URL: "${url}"`)
|
||||
.toBe('/attacker.com/deep/path');
|
||||
}
|
||||
});
|
||||
|
||||
it('should set the proper document location when the URL has leading slashes to prevent origin hijack', async () => {
|
||||
const urls = ['/\\attacker.com/deep/path', '//attacker.com/deep/path'];
|
||||
|
||||
for (const url of urls) {
|
||||
const platform = platformServer([
|
||||
{
|
||||
provide: INITIAL_CONFIG,
|
||||
useValue: {
|
||||
document: '<html><head></head><body></body></html>',
|
||||
url,
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const doc = platform.injector.get(DOCUMENT);
|
||||
platform.destroy();
|
||||
|
||||
expect(doc.location.origin).not.toBe('http://attacker.com');
|
||||
expect(doc.location.pathname).toBe('/attacker.com/deep/path');
|
||||
}
|
||||
});
|
||||
|
||||
it('should not expose protocol-relative URLs on the location to prevent open redirect and SSRF bypasses', async () => {
|
||||
const urls = ['/\\attacker.com/deep/path', '//attacker.com/deep/path'];
|
||||
const origins = [undefined, 'http://localhost:4200'];
|
||||
|
||||
for (const url of urls) {
|
||||
for (const origin of origins) {
|
||||
const providers: any[] = [
|
||||
{
|
||||
provide: INITIAL_CONFIG,
|
||||
useValue: {
|
||||
document: '',
|
||||
url,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
if (origin) {
|
||||
providers.push({
|
||||
provide: DOCUMENT,
|
||||
useValue: {
|
||||
location: {
|
||||
origin,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const platform = platformServer(providers);
|
||||
const location = platform.injector.get(PlatformLocation) as any;
|
||||
platform.destroy();
|
||||
|
||||
// A relative redirect URL starting with // or /\ is normalized by browsers to a protocol-relative URL.
|
||||
// The PlatformLocation.url property MUST NOT expose these unsafe patterns.
|
||||
const isVulnerable = location.url.startsWith('//') || location.url.startsWith('/\\');
|
||||
expect(isVulnerable)
|
||||
.withContext(`URL: "${url}", origin: "${origin}", location.url: "${location.url}"`)
|
||||
.toBeFalse();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright Google LLC All Rights Reserved.
|
||||
*
|
||||
* Use of this source code is governed by an MIT-style license that can be
|
||||
* found in the LICENSE file at https://angular.dev/license
|
||||
*/
|
||||
|
||||
import {parseUrl} from '../src/url';
|
||||
|
||||
describe('parseUrl', () => {
|
||||
describe('with origin', () => {
|
||||
it('should resolve relative paths against origin', () => {
|
||||
const url = parseUrl('/deep/path?query#hash', 'http://test.com');
|
||||
expect(url.href).toBe('http://test.com/deep/path?query#hash');
|
||||
expect(url.search).toBe('?query');
|
||||
expect(url.hash).toBe('#hash');
|
||||
});
|
||||
|
||||
it('should resolve absolute URLs ignoring origin', () => {
|
||||
const url = parseUrl('http://other.com/deep/path', 'http://test.com');
|
||||
expect(url.href).toBe('http://other.com/deep/path');
|
||||
expect(url.origin).toBe('http://other.com');
|
||||
});
|
||||
|
||||
it('should throw an error for malformed absolute URLs', () => {
|
||||
const malformedUrls = [
|
||||
'http://evil.com:80:80/path',
|
||||
'https://evil.com:80:80/path',
|
||||
'http://[google.com]/path',
|
||||
'http://google.com:port/path',
|
||||
'http://google.com:80a/path',
|
||||
];
|
||||
|
||||
for (const url of malformedUrls) {
|
||||
expect(() => parseUrl(url, 'http://test.com')).toThrowError(
|
||||
new RegExp(`Invalid URL: ${url.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`),
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('without origin', () => {
|
||||
it('should return null for relative paths', () => {
|
||||
expect(parseUrl('/deep/path?query#hash')).toBeNull();
|
||||
expect(parseUrl('deep/path')).toBeNull();
|
||||
});
|
||||
|
||||
it('should parse valid absolute URLs', () => {
|
||||
const url = parseUrl('http://other.com/deep/path');
|
||||
expect(url).not.toBeNull();
|
||||
expect(url!.href).toBe('http://other.com/deep/path');
|
||||
expect(url!.origin).toBe('http://other.com');
|
||||
});
|
||||
|
||||
it('should throw an error for malformed absolute URLs', () => {
|
||||
const malformedUrls = [
|
||||
'http://evil.com:80:80/path',
|
||||
'https://evil.com:80:80/path',
|
||||
'http://[google.com]/path',
|
||||
'http://google.com:port/path',
|
||||
'http://google.com:80a/path',
|
||||
];
|
||||
|
||||
for (const url of malformedUrls) {
|
||||
expect(() => parseUrl(url)).toThrowError(
|
||||
new RegExp(`Invalid URL: ${url.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`),
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -60,6 +60,28 @@ describe('allowedHosts validation in renderApplication', () => {
|
||||
expect(error.message).not.toContain('is not allowed');
|
||||
}
|
||||
});
|
||||
|
||||
it('should throw an error for malformed absolute URLs (SSRF bypass attempt)', async () => {
|
||||
const malformedUrls = [
|
||||
'http://evil.com:80:80/path',
|
||||
'https://evil.com:80:80/path',
|
||||
'http://[google.com]/path',
|
||||
'http://google.com:port/path',
|
||||
'http://google.com:80a/path',
|
||||
];
|
||||
|
||||
for (const url of malformedUrls) {
|
||||
await expectAsync(
|
||||
renderApplication(bootstrap, {
|
||||
document: '<app></app>',
|
||||
url,
|
||||
allowedHosts: ['test.com'],
|
||||
}),
|
||||
)
|
||||
.withContext(`URL: ${url}`)
|
||||
.toBeRejectedWithError(new RegExp(/Invalid URL:.+/));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('allowedHosts validation in renderModule', () => {
|
||||
@@ -94,4 +116,26 @@ describe('allowedHosts validation in renderModule', () => {
|
||||
expect(error.message).not.toContain('is not allowed');
|
||||
}
|
||||
});
|
||||
|
||||
it('should throw an error for malformed absolute URLs (SSRF bypass attempt)', async () => {
|
||||
const malformedUrls = [
|
||||
'http://evil.com:80:80/path',
|
||||
'https://evil.com:80:80/path',
|
||||
'http://[google.com]/path',
|
||||
'http://google.com:port/path',
|
||||
'http://google.com:80a/path',
|
||||
];
|
||||
|
||||
for (const url of malformedUrls) {
|
||||
await expectAsync(
|
||||
renderModule(MockModule, {
|
||||
document: '<app></app>',
|
||||
url,
|
||||
allowedHosts: ['test.com'],
|
||||
}),
|
||||
)
|
||||
.withContext(`URL: ${url}`)
|
||||
.toBeRejectedWithError(new RegExp(/Invalid URL:.+/));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user