diff options
Diffstat (limited to 'cli/js')
-rw-r--r-- | cli/js/tests/url_test.ts | 36 | ||||
-rw-r--r-- | cli/js/web/url.ts | 105 | ||||
-rw-r--r-- | cli/js/web/url_search_params.ts | 8 |
3 files changed, 112 insertions, 37 deletions
diff --git a/cli/js/tests/url_test.ts b/cli/js/tests/url_test.ts index 5b403fb1c..68fcbd95e 100644 --- a/cli/js/tests/url_test.ts +++ b/cli/js/tests/url_test.ts @@ -169,6 +169,42 @@ unitTest(function urlDriveLetter() { assertEquals(new URL("http://example.com/C:").href, "http://example.com/C:"); }); +unitTest(function urlHostnameUpperCase() { + assertEquals(new URL("https://EXAMPLE.COM").href, "https://example.com/"); +}); + +unitTest(function urlTrim() { + assertEquals(new URL(" https://example.com ").href, "https://example.com/"); +}); + +unitTest(function urlEncoding() { + assertEquals( + new URL("https://a !$&*()=,;+'\"@example.com").username, + "a%20!$&*()%3D,%3B+%27%22" + ); + assertEquals( + new URL("https://:a !$&*()=,;+'\"@example.com").password, + "a%20!$&*()%3D,%3B+%27%22" + ); + // FIXME: https://url.spec.whatwg.org/#idna + // assertEquals( + // new URL("https://a !$&*()=,+'\"").hostname, + // "a%20%21%24%26%2A%28%29%3D%2C+%27%22" + // ); + assertEquals( + new URL("https://example.com/a ~!@$&*()=:/,;+'\"\\").pathname, + "/a%20~!@$&*()=:/,;+'%22/" + ); + assertEquals( + new URL("https://example.com?a ~!@$&*()=:/,;?+'\"\\").search, + "?a%20~!@$&*()=:/,;?+%27%22\\" + ); + assertEquals( + new URL("https://example.com#a ~!@#$&*()=:/,;?+'\"\\").hash, + "#a%20~!@#$&*()=:/,;?+'%22\\" + ); +}); + unitTest(function urlBaseURL(): void { const base = new URL( "https://foo:bar@baz.qat:8000/qux/quux?foo=bar&baz=12#qat" diff --git a/cli/js/web/url.ts b/cli/js/web/url.ts index 41f855914..bbe405f85 100644 --- a/cli/js/web/url.ts +++ b/cli/js/web/url.ts @@ -11,7 +11,7 @@ interface URLParts { hostname: string; port: string; path: string; - query: string | null; + query: string; hash: string; } @@ -53,7 +53,7 @@ function takePattern(string: string, pattern: RegExp): [string, string] { function parse(url: string, isBase = true): URLParts | undefined { const parts: Partial<URLParts> = {}; let restUrl; - [parts.protocol, restUrl] = takePattern(url, /^([a-z]+):/); + [parts.protocol, restUrl] = takePattern(url.trim(), /^([a-z]+):/); if (isBase && parts.protocol == "") { return undefined; } @@ -61,9 +61,6 @@ function parse(url: string, isBase = true): URLParts | undefined { parts.username = ""; parts.password = ""; [parts.hostname, restUrl] = takePattern(restUrl, /^[/\\]{2}([^/\\?#]*)/); - if (parts.hostname.includes(":")) { - return undefined; - } parts.port = ""; } else if (specialSchemes.includes(parts.protocol)) { let restAuthority; @@ -80,7 +77,9 @@ function parse(url: string, isBase = true): URLParts | undefined { restAuthentication, /^([^:]*)/ ); + parts.username = encodeUserinfo(parts.username); [parts.password] = takePattern(restAuthentication, /^:(.*)/); + parts.password = encodeUserinfo(parts.password); [parts.hostname, restAuthority] = takePattern(restAuthority, /^([^:]+)/); [parts.port] = takePattern(restAuthority, /^:(.*)/); if (!isValidPort(parts.port)) { @@ -92,10 +91,17 @@ function parse(url: string, isBase = true): URLParts | undefined { parts.hostname = ""; parts.port = ""; } + try { + parts.hostname = encodeHostname(parts.hostname).toLowerCase(); + } catch { + return undefined; + } [parts.path, restUrl] = takePattern(restUrl, /^([^?#]*)/); - parts.path = parts.path.replace(/\\/g, "/"); + parts.path = encodePathname(parts.path.replace(/\\/g, "/")); [parts.query, restUrl] = takePattern(restUrl, /^(\?[^#]*)/); + parts.query = encodeSearch(parts.query); [parts.hash] = takePattern(restUrl, /^(#.*)/); + parts.hash = encodeHash(parts.hash); return parts as URLParts; } @@ -259,9 +265,7 @@ export class URLImpl implements URL { value = `#${value}`; } // hashes can contain % and # unescaped - parts.get(this)!.hash = escape(value) - .replace(/%25/g, "%") - .replace(/%23/g, "#"); + parts.get(this)!.hash = encodeHash(value); } } @@ -282,7 +286,9 @@ export class URLImpl implements URL { set hostname(value: string) { value = String(value); - parts.get(this)!.hostname = encodeURIComponent(value); + try { + parts.get(this)!.hostname = encodeHostname(value); + } catch {} } get href(): string { @@ -319,7 +325,7 @@ export class URLImpl implements URL { set password(value: string) { value = String(value); - parts.get(this)!.password = encodeURIComponent(value); + parts.get(this)!.password = encodeUserinfo(value); } get pathname(): string { @@ -332,7 +338,7 @@ export class URLImpl implements URL { value = `/${value}`; } // paths can contain % unescaped - parts.get(this)!.path = escape(value).replace(/%25/g, "%"); + parts.get(this)!.path = encodePathname(value); } get port(): string { @@ -366,27 +372,13 @@ export class URLImpl implements URL { } get search(): string { - const query = parts.get(this)!.query; - if (query === null || query === "") { - return ""; - } - - return query; + return parts.get(this)!.query; } set search(value: string) { value = String(value); - let query: string | null; - - if (value === "") { - query = null; - } else if (value.charAt(0) !== "?") { - query = `?${value}`; - } else { - query = value; - } - - parts.get(this)!.query = query; + const query = value == "" || value.charAt(0) == "?" ? value : `?${value}`; + parts.get(this)!.query = encodeSearch(query); this.#updateSearchParams(); } @@ -396,7 +388,7 @@ export class URLImpl implements URL { set username(value: string) { value = String(value); - parts.get(this)!.username = encodeURIComponent(value); + parts.get(this)!.username = encodeUserinfo(value); } get searchParams(): URLSearchParams { @@ -474,3 +466,56 @@ export class URLImpl implements URL { blobURLMap.delete(url); } } + +function charInC0ControlSet(c: string): boolean { + return c >= "\u0000" && c <= "\u001F"; +} + +function charInSearchSet(c: string): boolean { + // prettier-ignore + return charInC0ControlSet(c) || ["\u0020", "\u0022", "\u0023", "\u0027", "\u003C", "\u003E"].includes(c) || c > "\u007E"; +} + +function charInFragmentSet(c: string): boolean { + // prettier-ignore + return charInC0ControlSet(c) || ["\u0020", "\u0022", "\u003C", "\u003E", "\u0060"].includes(c); +} + +function charInPathSet(c: string): boolean { + // prettier-ignore + return charInFragmentSet(c) || ["\u0023", "\u003F", "\u007B", "\u007D"].includes(c); +} + +function charInUserinfoSet(c: string): boolean { + // "\u0027" ("'") seemingly isn't in the spec, but matches Chrome and Firefox. + // prettier-ignore + return charInPathSet(c) || ["\u0027", "\u002F", "\u003A", "\u003B", "\u003D", "\u0040", "\u005B", "\u005C", "\u005D", "\u005E", "\u007C"].includes(c); +} + +function encodeChar(c: string): string { + return `%${c.charCodeAt(0).toString(16)}`.toUpperCase(); +} + +function encodeUserinfo(s: string): string { + return [...s].map((c) => (charInUserinfoSet(c) ? encodeChar(c) : c)).join(""); +} + +function encodeHostname(s: string): string { + // FIXME: https://url.spec.whatwg.org/#idna + if (s.includes(":")) { + throw new TypeError("Invalid hostname."); + } + return encodeURIComponent(s); +} + +function encodePathname(s: string): string { + return [...s].map((c) => (charInPathSet(c) ? encodeChar(c) : c)).join(""); +} + +function encodeSearch(s: string): string { + return [...s].map((c) => (charInSearchSet(c) ? encodeChar(c) : c)).join(""); +} + +function encodeHash(s: string): string { + return [...s].map((c) => (charInFragmentSet(c) ? encodeChar(c) : c)).join(""); +} diff --git a/cli/js/web/url_search_params.ts b/cli/js/web/url_search_params.ts index 35439b0e2..2abac3cd0 100644 --- a/cli/js/web/url_search_params.ts +++ b/cli/js/web/url_search_params.ts @@ -76,13 +76,7 @@ export class URLSearchParamsImpl implements URLSearchParams { if (url == null) { return; } - - let query: string | null = this.toString(); - if (query === "") { - query = null; - } - - parts.get(url)!.query = query; + parts.get(url)!.query = this.toString(); }; #append = (name: string, value: string): void => { |