66 * found in the LICENSE file at https://angular.dev/license
77 */
88
9- const LEADING_SLASHES_REGEX = / ^ [ / \\ ] + / ;
10- const MALFORMED_ABSOLUTE_URL_REGEX = / ^ [ a - z A - Z ] [ a - z A - Z 0 - 9 + . - ] * : ( \/ \/ | \\ \\ ) / ;
9+ /**
10+ * Matches http: or https:
11+ */
12+ const HTTP_OR_HTTPS_PROTOCOL_REGEX = / ^ h t t p s ? : / i;
13+
14+ /**
15+ * Options for {@link parseUrl}.
16+ */
17+ export interface ParseUrlOptions {
18+ /**
19+ * Allow protocol-relative URLs (e.g. `//example.com`).
20+ */
21+ allowProtocolRelative ?: boolean ;
22+ }
1123
1224/**
1325 * Parses a URL string and returns a resolved WHATWG URL object.
@@ -16,32 +28,89 @@ const MALFORMED_ABSOLUTE_URL_REGEX = /^[a-zA-Z][a-zA-Z0-9+.-]*:(\/\/|\\\\)/;
1628 * If an origin is provided, relative URLs and protocol-relative URLs are normalized and resolved against it.
1729 */
1830export function parseUrl ( urlStr : string | undefined ) : URL | null ;
19- export function parseUrl ( urlStr : string | undefined , origin : string ) : URL ;
20- export function parseUrl ( urlStr : string | undefined , origin ?: string ) : URL | null {
31+ export function parseUrl (
32+ urlStr : string | undefined ,
33+ origin : string | URL ,
34+ options ?: ParseUrlOptions ,
35+ ) : URL ;
36+ export function parseUrl (
37+ urlStr : string | undefined ,
38+ origin ?: string | URL ,
39+ options : ParseUrlOptions = { } ,
40+ ) : URL | null {
41+ const originUrl = typeof origin === 'string' ? new URL ( '/' , origin ) : origin ;
42+
2143 if ( ! urlStr ) {
22- return origin !== undefined ? new URL ( '/' , origin ) : null ;
44+ return originUrl || null ;
2345 }
2446
25- if ( URL . canParse ( urlStr ) ) {
26- return new URL ( urlStr ) ;
47+ urlStr = urlStr . trim ( ) ;
48+
49+ // Fast-path: if the URL is a valid, standard absolute URL, parse and return it immediately.
50+ let resolved : URL | undefined ;
51+ try {
52+ resolved = new URL ( urlStr ) ;
53+ } catch { }
54+
55+ if ( resolved ) {
56+ if ( originUrl && ! isSafeOriginChange ( resolved , originUrl , urlStr ) ) {
57+ throwSuspiciousUrlError ( urlStr ) ;
58+ }
59+
60+ return resolved ;
2761 }
2862
29- if ( MALFORMED_ABSOLUTE_URL_REGEX . test ( urlStr ) ) {
63+ // We identify and throw on malformed absolute URLs (like double port).
64+ // Per the WHATWG URL standard, parsing an input starting with a scheme (like 'http:') against
65+ // a standard base (like 'http://fake') ignores the base argument and parses strictly as an
66+ // absolute URL. Since it is malformed, the native URL constructor will throw a validation
67+ // error. Standard relative/protocol-relative paths parse successfully, allowing the flow to continue.
68+ if ( ! URL . canParse ( urlStr , 'http://fake' ) ) {
3069 throw new Error ( `Invalid URL: ${ urlStr } ` ) ;
3170 }
3271
33- if ( origin === undefined ) {
72+ if ( ! originUrl ) {
3473 return null ;
3574 }
3675
37- // Normalizes request path parsing by collapsing multiple consecutive leading slashes
38- // and backslashes (e.g. // or /\) down to a single forward slash. This ensures consistent
39- // resolution of relative path segments and prevents unexpected absolute path overrides
40- // during URL parsing.
41- let normalizedPath = urlStr . replace ( LEADING_SLASHES_REGEX , '/' ) ;
42- if ( normalizedPath [ 0 ] !== '/' ) {
43- normalizedPath = `/${ normalizedPath } ` ;
76+ const { allowProtocolRelative = false } = options ;
77+
78+ // Check if we have a legitimate protocol-relative URL (starts with '//' and not a duplicate/backslash bypass)
79+ // and we are configured to allow and preserve standard cross-origin protocol-relative requests.
80+ if ( urlStr . startsWith ( '//' ) ) {
81+ if ( ! allowProtocolRelative ) {
82+ throw new Error ( `Protocol relative URLs are not allowed in this context. URL: ${ urlStr } ` ) ;
83+ }
84+
85+ return new URL ( urlStr , origin ) ;
86+ }
87+
88+ resolved = new URL ( urlStr , origin ) ;
89+
90+ if ( ! isSafeOriginChange ( resolved , originUrl , urlStr ) ) {
91+ throwSuspiciousUrlError ( urlStr ) ;
4492 }
4593
46- return new URL ( normalizedPath , origin ) ;
94+ return resolved ;
95+ }
96+
97+ /**
98+ * Throws a suspicious URL error indicating a security bypass attempt.
99+ */
100+ function throwSuspiciousUrlError ( urlStr : string ) : never {
101+ throw new Error (
102+ `URL ${ urlStr } changed origin unexpectedly. This is suspicious and may indicate a security bypass attempt.` ,
103+ ) ;
104+ }
105+
106+ /**
107+ * Checks if the origin has changed in a safe way.
108+ *
109+ * @param resolved The resolved URL.
110+ * @param origin The origin URL.
111+ * @param urlStr The URL string.
112+ * @returns True if the origin has changed in a safe way, false otherwise.
113+ */
114+ function isSafeOriginChange ( resolved : URL , origin : URL , urlStr : string ) : boolean {
115+ return origin . origin === resolved . origin || HTTP_OR_HTTPS_PROTOCOL_REGEX . test ( urlStr ) ;
47116}
0 commit comments