refactor(platform-server): clean up and simplify url resolution utility

Trims leading/trailing whitespaces in resolveUrl to normalize input.

(cherry picked from commit e14d34e9ee)
This commit is contained in:
Alan Agius
2026-05-29 13:14:07 +02:00
committed by Pawel Kozlowski
parent d3170031b6
commit 8206972189
8 changed files with 152 additions and 57 deletions
+4 -12
View File
@@ -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()}));
}
+3 -3
View File
@@ -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<this>;
writableThis.pathname = pathname;
writableThis.search = search;
+4 -2
View File
@@ -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;
+71 -15
View File
@@ -6,26 +6,53 @@
* 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)) {
return new URL(urlStr);
}
urlStr = urlStr.trim();
if (/^[a-zA-Z][a-zA-Z0-9+.-]*:(\/\/|\\\\)/.test(urlStr)) {
// Fast-path: if the URL is a valid, standard absolute URL, parse and return it immediately.
try {
return new URL(urlStr);
} catch {}
// 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 standard base (like 'http://fake') 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 (!URL.canParse(urlStr, 'http://fake')) {
throw new Error(`Invalid URL: ${urlStr}`);
}
@@ -33,10 +60,39 @@ export function parseUrl(urlStr: string | undefined, origin?: string): URL | nul
return null;
}
let normalizedPath = urlStr.replace(LEADING_SLASHES_REGEX, '/');
if (normalizedPath[0] !== '/') {
normalizedPath = `/${normalizedPath}`;
const {allowProtocolRelative = false} = options;
// 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);
}
+2 -2
View File
@@ -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<string> = new Set(allowedHosts);
@@ -1593,7 +1593,9 @@ class HiddenModule {}
http.get(badUrl).subscribe((body) => {
expect(body).toEqual('success!');
});
mock.expectOne('http://localhost:4000/attacker.com').flush('success!');
mock
.expectOne('http://localhost:4000/attacker.com', `URL: ${badUrl}`)
.flush('success!');
}
});
});
+21 -9
View File
@@ -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, '\\$&')}`),
);
}
+44 -13
View File
@@ -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: '<app></app>',
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: '<app></app>',
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: '<app></app>',
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: '<app></app>',
url,
allowedHosts: ['test.com'],