diff --git a/packages/platform-server/src/http.ts b/packages/platform-server/src/http.ts index e6381b1a9f9..837a985db5c 100644 --- a/packages/platform-server/src/http.ts +++ b/packages/platform-server/src/http.ts @@ -15,6 +15,7 @@ import { } from '@angular/common/http'; import {inject, Injectable, Provider} from '@angular/core'; import {Observable} from 'rxjs'; +import {resolveUrl} from './url'; @Injectable() export class ServerXhr implements XhrFactory { @@ -70,18 +71,9 @@ function relativeUrlsTransformerInterceptorFn( const baseHref = platformLocation.getBaseHrefFromDOM() || href; const baseUrl = new URL(baseHref, urlPrefix); - 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); - } - } + const parsedUrl = resolveUrl(request.url, baseUrl, { + allowProtocolRelative: true, + }); return next(request.clone({url: parsedUrl.toString()})); } diff --git a/packages/platform-server/src/location.ts b/packages/platform-server/src/location.ts index 3f3767f677c..66c2e91816d 100644 --- a/packages/platform-server/src/location.ts +++ b/packages/platform-server/src/location.ts @@ -17,7 +17,7 @@ import {inject, Injectable, ɵWritable as Writable} from '@angular/core'; import {Subject} from 'rxjs'; import {INITIAL_CONFIG} from './tokens'; -import {parseUrl} from './url'; +import {resolveUrl} from './url'; /** * Server-side implementation of URL state. Implements `pathname`, `search`, and `hash` @@ -41,7 +41,7 @@ export class ServerPlatformLocation implements PlatformLocation { return; } if (config.url) { - const {protocol, hostname, port, pathname, search, hash, href} = parseUrl( + const {protocol, hostname, port, pathname, search, hash, href} = resolveUrl( config.url, this._doc.location.origin, ); @@ -93,7 +93,7 @@ export class ServerPlatformLocation implements PlatformLocation { replaceState(state: any, title: string, newUrl: string): void { const oldUrl = this.url; - const {pathname, search, hash, href, protocol} = parseUrl(newUrl, this._doc.location.origin); + const {pathname, search, hash, href, protocol} = resolveUrl(newUrl, this._doc.location.origin); const writableThis = this as Writable; writableThis.pathname = pathname; writableThis.search = search; diff --git a/packages/platform-server/src/server.ts b/packages/platform-server/src/server.ts index be938da1c65..82ab81aea8e 100644 --- a/packages/platform-server/src/server.ts +++ b/packages/platform-server/src/server.ts @@ -37,7 +37,7 @@ import { import {DominoAdapter, parseDocument} from './domino_adapter'; import {SERVER_HTTP_PROVIDERS} from './http'; -import {parseUrl} from './url'; +import {resolveUrl} from './url'; import {ServerPlatformLocation} from './location'; import {enableDomEmulation, PlatformState} from './platform_state'; import {ServerEventManagerPlugin} from './server_events'; @@ -103,7 +103,9 @@ function _document() { ? _enableDomEmulation ? parseDocument( config.document, - config.url !== undefined ? parseUrl(config.url, 'http://localhost').href : undefined, + config.url !== undefined + ? resolveUrl(config.url, 'http://localhost').href + : undefined, ) : window.document : config.document; diff --git a/packages/platform-server/src/url.ts b/packages/platform-server/src/url.ts index 6eaa1ebd2ec..b49536d2c64 100644 --- a/packages/platform-server/src/url.ts +++ b/packages/platform-server/src/url.ts @@ -6,26 +6,55 @@ * found in the LICENSE file at https://angular.dev/license */ -const LEADING_SLASHES_REGEX = /^[/\\]+/; +/** + * Options for {@link resolveUrl}. + */ +export interface ResolveUrlOptions { + /** + * Allow protocol-relative URLs (e.g. `//example.com`). + */ + allowProtocolRelative?: boolean; +} /** - * 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. + * Resolves a URL string. + * + * If an origin is provided, the URL is resolved against it. Otherwise, the URL is parsed as-is. + * @param urlStr The URL to resolve. + * @param origin The origin to resolve the URL against. + * @param options Options for resolving the URL. + * @returns A resolved URL object. */ -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 { +export function resolveUrl(urlStr: string | undefined): URL | null; +export function resolveUrl( + urlStr: string | undefined, + origin: string | URL, + options?: ResolveUrlOptions, +): URL; +export function resolveUrl( + urlStr: string | undefined, + origin?: string | URL, + options: ResolveUrlOptions = {}, +): URL | null { if (!urlStr) { return origin !== undefined ? new URL('/', origin) : null; } - if (URL.canParse(urlStr)) { + urlStr = urlStr.trim(); + + // Fast-path: if the URL is a valid, standard absolute URL, parse and return it immediately. + try { return new URL(urlStr); - } + } catch {} - if (/^[a-zA-Z][a-zA-Z0-9+.-]*:(\/\/|\\\\)/.test(urlStr)) { + const {allowProtocolRelative = false} = options; + + // We identify and throw on malformed absolute URLs (like double port). + // Per the WHATWG URL standard, parsing an input starting with a scheme (like 'http:') against + // a non-standard base ('resolve://') ignores the base argument and parses strictly as an + // absolute URL. Since it is malformed, the native URL constructor will throw a validation + // error. Standard relative/protocol-relative paths parse successfully, allowing the flow to continue. + if (!allowProtocolRelative && !URL.canParse(urlStr, 'resolve://')) { throw new Error(`Invalid URL: ${urlStr}`); } @@ -33,10 +62,37 @@ export function parseUrl(urlStr: string | undefined, origin?: string): URL | nul return null; } - let normalizedPath = urlStr.replace(LEADING_SLASHES_REGEX, '/'); - if (normalizedPath[0] !== '/') { - normalizedPath = `/${normalizedPath}`; + // Check if we have a legitimate protocol-relative URL (starts with '//' and not a duplicate/backslash bypass) + // and we are configured to allow and preserve standard cross-origin protocol-relative requests. + const isProtocolRelative = + allowProtocolRelative && + urlStr[0] === '/' && + urlStr[1] === '/' && + urlStr.length > 2 && + urlStr[2] !== '/' && + urlStr[2] !== '\\'; + + if (isProtocolRelative) { + return new URL(urlStr, origin); + } + + // Safe relative path preservation: if a relative path has no leading forward or backward slashes, + // we do not prepend any slash so the native URL constructor can resolve it correctly relative + // to trailing-slash sub-paths (e.g., 'testing' against 'http://localhost/foo/' -> 'http://localhost/foo/testing'). + const startsWithSlash = urlStr[0] === '/' || urlStr[0] === '\\'; + if (!startsWithSlash) { + return new URL(urlStr, origin); + } + + // For other relative inputs starting with slashes, we collapse all consecutive leading forward/backward + // slashes to a single forward slash. This guarantees consistent same-origin path representation and + // blocks any hostname hijack or takeover attempts. + let startIdx = 0; + while (startIdx < urlStr.length && (urlStr[startIdx] === '/' || urlStr[startIdx] === '\\')) { + startIdx++; } + const pathWithoutLeadingSlashes = urlStr.slice(startIdx); + const normalizedPath = '/' + pathWithoutLeadingSlashes; return new URL(normalizedPath, origin); } diff --git a/packages/platform-server/src/utils.ts b/packages/platform-server/src/utils.ts index 80a05ccc963..60eaf332a99 100644 --- a/packages/platform-server/src/utils.ts +++ b/packages/platform-server/src/utils.ts @@ -29,7 +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'; +import {resolveUrl} from './url'; /** * Event dispatch (JSAction) script is inlined into the HTML by the build @@ -379,7 +379,7 @@ export async function renderApplication( function validateAllowedHosts(url: string | undefined, allowedHosts: string[] | undefined) { if (typeof url === 'string') { - const parsedUrl = parseUrl(url); + const parsedUrl = resolveUrl(url); if (parsedUrl !== null) { const hostname = parsedUrl.hostname; const allowedHostsSet: ReadonlySet = new Set(allowedHosts); diff --git a/packages/platform-server/test/url_spec.ts b/packages/platform-server/test/url_spec.ts index 95d9095bf3e..be873a1d0d1 100644 --- a/packages/platform-server/test/url_spec.ts +++ b/packages/platform-server/test/url_spec.ts @@ -6,19 +6,28 @@ * found in the LICENSE file at https://angular.dev/license */ -import {parseUrl} from '../src/url'; +import {resolveUrl} from '../src/url'; -describe('parseUrl', () => { +describe('resolveUrl', () => { describe('with origin', () => { it('should resolve relative paths against origin', () => { - const url = parseUrl('/deep/path?query#hash', 'http://test.com'); + const url = resolveUrl('/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 neutralize backslash-prefixed hijack attempts by forcing them same-origin', () => { + const urls = ['/\\attacker.com/deep/path', '\\\\attacker.com/deep/path']; + for (const url of urls) { + const parsed = resolveUrl(url, 'http://test.com'); + expect(parsed.origin).toBe('http://test.com'); + expect(parsed.pathname).toBe('/attacker.com/deep/path'); + } + }); + it('should resolve absolute URLs ignoring origin', () => { - const url = parseUrl('http://other.com/deep/path', 'http://test.com'); + const url = resolveUrl('http://other.com/deep/path', 'http://test.com'); expect(url.href).toBe('http://other.com/deep/path'); expect(url.origin).toBe('http://other.com'); }); @@ -33,7 +42,7 @@ describe('parseUrl', () => { ]; for (const url of malformedUrls) { - expect(() => parseUrl(url, 'http://test.com')).toThrowError( + expect(() => resolveUrl(url, 'http://test.com')).toThrowError( new RegExp(`Invalid URL: ${url.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`), ); } @@ -42,12 +51,14 @@ describe('parseUrl', () => { describe('without origin', () => { it('should return null for relative paths', () => { - expect(parseUrl('/deep/path?query#hash')).toBeNull(); - expect(parseUrl('deep/path')).toBeNull(); + expect(resolveUrl('/deep/path?query#hash')).toBeNull(); + expect(resolveUrl('deep/path')).toBeNull(); + expect(resolveUrl('/\\attacker.com/deep/path')).toBeNull(); + expect(resolveUrl('\\\\attacker.com/deep/path')).toBeNull(); }); it('should parse valid absolute URLs', () => { - const url = parseUrl('http://other.com/deep/path'); + const url = resolveUrl('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'); @@ -60,10 +71,11 @@ describe('parseUrl', () => { 'http://[google.com]/path', 'http://google.com:port/path', 'http://google.com:80a/path', + 'ht\ntp://evil.com:80:80/path', ]; for (const url of malformedUrls) { - expect(() => parseUrl(url)).toThrowError( + expect(() => resolveUrl(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 7abc515df72..198eae72e32 100644 --- a/packages/platform-server/test/utils_spec.ts +++ b/packages/platform-server/test/utils_spec.ts @@ -6,10 +6,24 @@ * found in the LICENSE file at https://angular.dev/license */ -import {destroyPlatform} from '@angular/core'; -import {renderApplication, renderModule} from '@angular/platform-server'; +import {Component, destroyPlatform, NgModule} from '@angular/core'; +import {renderApplication, renderModule, ServerModule} from '@angular/platform-server'; import {isHostAllowed} from '../src/utils'; +@Component({ + selector: 'app', + template: 'works!', + standalone: false, +}) +class MockComponent {} + +@NgModule({ + declarations: [MockComponent], + bootstrap: [MockComponent], + imports: [ServerModule], +}) +class MockNgModule {} + describe('isHostAllowed', () => { it('allows matching hostname when in allowedHosts list', () => { expect(isHostAllowed('test.com', new Set(['test.com', 'example.com']))).toBeTrue(); @@ -29,7 +43,14 @@ describe('isHostAllowed', () => { }); describe('allowedHosts validation in renderApplication', () => { - const bootstrap = (async () => {}) as any; + const mockApplicationRef = { + injector: { + get: (token: any, defaultValue?: any) => defaultValue, + }, + whenStable: () => Promise.resolve(), + components: [], + } as any; + const bootstrap = (async () => mockApplicationRef) as any; beforeEach(() => { destroyPlatform(); @@ -39,14 +60,20 @@ describe('allowedHosts validation in renderApplication', () => { destroyPlatform(); }); - it('should throw an error on bootstrap if host is not allowed', async () => { - await expectAsync( - renderApplication(bootstrap, { - document: '', - url: 'http://evil.com/deep/path', - allowedHosts: ['test.com', '*.example.com'], - }), - ).toBeRejectedWithError(/Host http:\/\/evil.com\/deep\/path is not allowed/); + it('should reject URLs with wrong host', async () => { + const relativeUrls = ['http://evil.com/deep/path', 'ht\ttp://evil.com/deep/path']; + + for (const url of relativeUrls) { + await expectAsync( + renderApplication(bootstrap, { + document: '', + url, + allowedHosts: ['test.com', 'localhost'], + }), + ) + .withContext(`URL: ${url}`) + .toBeRejectedWithError(/Host .+ is not allowed/); + } }); it('should not throw a host validation error on bootstrap if host is allowed', async () => { @@ -68,6 +95,8 @@ describe('allowedHosts validation in renderApplication', () => { 'http://[google.com]/path', 'http://google.com:port/path', 'http://google.com:80a/path', + 'ht\ttp://evil.com:80:80/path', + 'ht\ntp://evil.com:80:80/path', ]; for (const url of malformedUrls) { @@ -97,7 +126,7 @@ describe('allowedHosts validation in renderModule', () => { it('should throw an error if host is not allowed', async () => { await expectAsync( - renderModule(MockModule, { + renderModule(MockNgModule, { document: '', url: 'http://evil.com/deep/path', allowedHosts: ['test.com', '*.example.com'], @@ -124,11 +153,13 @@ describe('allowedHosts validation in renderModule', () => { 'http://[google.com]/path', 'http://google.com:port/path', 'http://google.com:80a/path', + 'ht\ttp://evil.com:80:80/path', + 'ht\ntp://evil.com:80:80/path', ]; for (const url of malformedUrls) { await expectAsync( - renderModule(MockModule, { + renderModule(MockNgModule, { document: '', url, allowedHosts: ['test.com'],