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:
Alan Agius
2026-05-27 12:49:20 +00:00
committed by Alex Rickabaugh
parent b56e865148
commit 1307ff355c
7 changed files with 240 additions and 43 deletions
+1 -18
View File
@@ -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`
+5 -1
View File
@@ -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 {
+42
View File
@@ -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);
}
+9 -5
View File
@@ -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();
}
}
});
});
+72
View File
@@ -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:.+/));
}
});
});