mirror of
https://github.com/angular/angular.git
synced 2026-09-14 13:54:52 +08:00
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:
committed by
Pawel Kozlowski
parent
d3170031b6
commit
8206972189
@@ -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()}));
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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!');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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, '\\$&')}`),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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'],
|
||||
|
||||
Reference in New Issue
Block a user