From 099af33456ed120ece1c60ba0810ee6e6cd126d4 Mon Sep 17 00:00:00 2001 From: Alan Agius <17563226+alan-agius4@users.noreply.github.com> Date: Wed, 27 May 2026 12:49:20 +0000 Subject: [PATCH] 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. --- packages/platform-server/src/location.ts | 19 +--- packages/platform-server/src/server.ts | 6 +- packages/platform-server/src/url.ts | 42 +++++++++ packages/platform-server/src/utils.ts | 14 +-- .../test/platform_location_spec.ts | 86 +++++++++++++++---- packages/platform-server/test/url_spec.ts | 72 ++++++++++++++++ packages/platform-server/test/utils_spec.ts | 44 ++++++++++ 7 files changed, 240 insertions(+), 43 deletions(-) create mode 100644 packages/platform-server/src/url.ts create mode 100644 packages/platform-server/test/url_spec.ts diff --git a/packages/platform-server/src/location.ts b/packages/platform-server/src/location.ts index c156610c54b2..e52f963cd3ab 100644 --- a/packages/platform-server/src/location.ts +++ b/packages/platform-server/src/location.ts @@ -17,24 +17,7 @@ import {Inject, Injectable, Optional, ɵWritable as Writable} from '@angular/cor import {Subject} from 'rxjs'; import {INITIAL_CONFIG, PlatformConfig} 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` diff --git a/packages/platform-server/src/server.ts b/packages/platform-server/src/server.ts index f48b14f69c5b..69d608592cb5 100644 --- a/packages/platform-server/src/server.ts +++ b/packages/platform-server/src/server.ts @@ -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'; @@ -98,7 +99,10 @@ function _document(injector: Injector) { 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 { diff --git a/packages/platform-server/src/url.ts b/packages/platform-server/src/url.ts new file mode 100644 index 000000000000..6eaa1ebd2ec2 --- /dev/null +++ b/packages/platform-server/src/url.ts @@ -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); +} diff --git a/packages/platform-server/src/utils.ts b/packages/platform-server/src/utils.ts index 0199afd83fa1..badded739d35 100644 --- a/packages/platform-server/src/utils.ts +++ b/packages/platform-server/src/utils.ts @@ -28,6 +28,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 @@ -374,11 +375,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 = 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 = new Set(allowedHosts); + if (!isHostAllowed(hostname, allowedHostsSet)) { + throw new Error(`Host ${url} is not allowed. You can configure \`allowedHosts\` option.`); + } } } } diff --git a/packages/platform-server/test/platform_location_spec.ts b/packages/platform-server/test/platform_location_spec.ts index ee3cde062dc3..ec5a1f6e5ab7 100644 --- a/packages/platform-server/test/platform_location_spec.ts +++ b/packages/platform-server/test/platform_location_spec.ts @@ -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: '', + 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(); + } } }); }); diff --git a/packages/platform-server/test/url_spec.ts b/packages/platform-server/test/url_spec.ts new file mode 100644 index 000000000000..95d9095bf3ed --- /dev/null +++ b/packages/platform-server/test/url_spec.ts @@ -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, '\\$&')}`), + ); + } + }); + }); +}); diff --git a/packages/platform-server/test/utils_spec.ts b/packages/platform-server/test/utils_spec.ts index 8850b535743f..7abc515df72c 100644 --- a/packages/platform-server/test/utils_spec.ts +++ b/packages/platform-server/test/utils_spec.ts @@ -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: '', + 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: '', + url, + allowedHosts: ['test.com'], + }), + ) + .withContext(`URL: ${url}`) + .toBeRejectedWithError(new RegExp(/Invalid URL:.+/)); + } + }); });