taler-typescript-core

Wallet core logic and WebUIs for various components
Log | Files | Refs | Submodules | README | LICENSE

commit 53c79a44c78f35068f3895765bff47e90646b4d9
parent 6d2bf179e48ab4c6de1b95bd180df250461fb0d6
Author: Florian Dold <dold@taler.net>
Date:   Fri, 31 Jul 2026 11:54:11 +0200

util: fix the URL parser spec derivations

Diffstat:
Apackages/taler-util/src/whatwg-url.test.ts | 130+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-util/src/whatwg-url.ts | 83+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------
2 files changed, 197 insertions(+), 16 deletions(-)

diff --git a/packages/taler-util/src/whatwg-url.test.ts b/packages/taler-util/src/whatwg-url.test.ts @@ -0,0 +1,130 @@ +/* + This file is part of GNU Taler + (C) 2026 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +import assert from "node:assert"; +import { test } from "node:test"; +import { URL } from "./url.js"; + +// The setters below all re-run the URL parser with a state override, which +// tells it to update one component and stop instead of continuing into the +// next parser state. Without that flag the parser falls through, most +// visibly by appending an empty path segment. + +test("the hostname setter leaves the path alone", (t) => { + const root = new URL("https://example.com/"); + root.hostname = "exchange.example.com"; + assert.strictEqual(root.href, "https://exchange.example.com/"); + + const withPath = new URL("https://example.com/foo/bar"); + withPath.hostname = "x.example.com"; + assert.strictEqual(withPath.href, "https://x.example.com/foo/bar"); +}); + +test("the hostname setter keeps the port", (t) => { + const u = new URL("https://example.com:8081/"); + u.hostname = "exchange.example.com"; + assert.strictEqual(u.href, "https://exchange.example.com:8081/"); +}); + +test("the host setter takes host and port together", (t) => { + const u = new URL("https://example.com/"); + u.host = "x.example.com:99"; + assert.strictEqual(u.href, "https://x.example.com:99/"); +}); + +test("the port setter can clear the port", (t) => { + const u = new URL("https://example.com:8081/"); + u.port = ""; + assert.strictEqual(u.href, "https://example.com/"); + + const other = new URL("https://example.com/"); + other.port = "1234"; + assert.strictEqual(other.href, "https://example.com:1234/"); +}); + +test("the protocol setter replaces only the scheme", (t) => { + const u = new URL("https://example.com/foo"); + u.protocol = "http:"; + assert.strictEqual(u.href, "http://example.com/foo"); +}); + +test("the pathname setter replaces the whole path", (t) => { + const u = new URL("https://example.com/a/b"); + u.pathname = "/c"; + assert.strictEqual(u.href, "https://example.com/c"); +}); + +test("setting a component twice is idempotent", (t) => { + const u = new URL("https://example.com/"); + u.hostname = "exchange.example.com"; + u.hostname = "exchange.example.com"; + assert.strictEqual(u.href, "https://exchange.example.com/"); +}); + +// The cases below are taken from the Web Platform Tests URL corpus +// (url/resources/urltestdata.json). + +test("an ASCII host is lowercased", (t) => { + assert.strictEqual( + new URL("http://ExAmPlE.CoM", "http://other.com/").href, + "http://example.com/", + ); + assert.strictEqual( + new URL("http://a.b.c.XN--pokxncvks").host, + "a.b.c.xn--pokxncvks", + ); + assert.strictEqual(new URL("http://0X7F.0.0.0X7G").host, "0x7f.0.0.0x7g"); +}); + +test("a scheme-only URL keeps an empty opaque path", (t) => { + assert.strictEqual(new URL("sc:", "https://example.org/foo/bar").href, "sc:"); + assert.strictEqual(new URL("sc:").pathname, ""); + assert.strictEqual(new URL("blob:").href, "blob:"); +}); + +test("a relative reference against an opaque base is rejected", (t) => { + assert.throws(() => new URL("test-a-colon.html", "a:"), TypeError); +}); + +test("a space in an opaque path is encoded only before ? or #", (t) => { + assert.strictEqual( + new URL("non-special:opaque ?hi").href, + "non-special:opaque %20?hi", + ); + assert.strictEqual( + new URL("non-special:opaque #hi").href, + "non-special:opaque %20#hi", + ); + // Not followed by ? or #, so the space stays as it is. + assert.strictEqual(new URL("non-special:op ok").href, "non-special:op ok"); +}); + +test("a caret in the path is percent-encoded", (t) => { + assert.strictEqual(new URL("foo://host/a^b").pathname, "/a%5Eb"); + assert.strictEqual(new URL("wss://host/a^b").pathname, "/a%5Eb"); +}); + +test("a non-BMP character in userinfo does not eat the next one", (t) => { + // The emoji is a surrogate pair; the "x" after it must survive. + assert.strictEqual( + new URL("http://\u{1F600}x@host/").href, + "http://%F0%9F%98%80x@host/", + ); + assert.strictEqual( + new URL("http://a:\u{1F600}x@host/").href, + "http://a:%F0%9F%98%80x@host/", + ); +}); diff --git a/packages/taler-util/src/whatwg-url.ts b/packages/taler-util/src/whatwg-url.ts @@ -217,7 +217,13 @@ function isSpecialQueryPercentEncode(c: number) { } // https://url.spec.whatwg.org/#path-percent-encode-set -const extraPathPercentEncodeSet = new Set([p("?"), p("`"), p("{"), p("}")]); +const extraPathPercentEncodeSet = new Set([ + p("?"), + p("`"), + p("{"), + p("}"), + p("^"), +]); function isPathPercentEncode(c: number) { return isQueryPercentEncode(c) || extraPathPercentEncodeSet.has(c); } @@ -342,6 +348,10 @@ function isASCIIHex(c: number) { ); } +function isASCIIString(s: string) { + return !/[^\u0000-\u007F]/u.test(s); +} + export class URLSearchParamsImpl { _list: any[]; _url: any; @@ -849,7 +859,7 @@ function parseHost(input: string, isNotSpecialArg = false) { } const domain = utf8DecodeWithoutBOM(percentDecodeString(input)); - const asciiDomain = domainToASCII(domain); + const asciiDomain = domainParser(domain); if (asciiDomain === failure) { return failure; } @@ -940,14 +950,16 @@ function serializeHost(host: number | number[] | string) { import { punycode } from "./punycode.js"; +/** + * https://url.spec.whatwg.org/#concept-domain-to-ASCII + * + * Upstream uses tr46 for full IDNA processing; this copy substitutes punycode, + * which handles the Punycode encoding but not the IDNA mapping and validation + * steps around it. Domains that need those steps (non-ASCII input requiring + * normalization, disallowed code points, bidi and joiner rules) are therefore + * accepted here where the standard would reject them. + */ function domainToASCII(domain: string, beStrict = false) { - // const result = tr46.toASCII(domain, { - // checkBidi: true, - // checkHyphens: false, - // checkJoiners: true, - // useSTD3ASCIIRules: beStrict, - // verifyDNSLength: beStrict, - // }); let result; try { result = punycode.toASCII(domain); @@ -960,6 +972,25 @@ function domainToASCII(domain: string, beStrict = false) { return result; } +/** + * https://url.spec.whatwg.org/#concept-domain-to-ASCII, the caller's half. + * + * An all-ASCII domain is lowercased and returned without going through + * domain-to-ASCII at all: the standard requires that for web compatibility, + * regardless of what ToASCII would have made of it. + */ +function domainParser(domain: string, beStrict = false) { + if (isASCIIString(domain)) { + return domain.toLowerCase(); + } + + const result = domainToASCII(domain, beStrict); + if (result === failure) { + return failure; + } + return result; +} + function trimControlChars(url: string) { return url.replace(/^[\u0000-\u001F\u0020]+|[\u0000-\u001F\u0020]+$/gu, ""); } @@ -1006,7 +1037,11 @@ export interface UrlObj { password: string; host: string | number[] | number | null | undefined; port: number | null; - path: string[]; + /** + * List of path segments, or a string for an opaque path (see + * hasAnOpaquePath). + */ + path: any; query: any; fragment: any; } @@ -1065,6 +1100,7 @@ class URLStateMachine { } input = res; + this.stateOverride = stateOverride; this.state = stateOverride || "scheme start"; this.buffer = ""; @@ -1186,7 +1222,10 @@ class URLStateMachine { this.state = "path or authority"; ++this.pointer; } else { - this.url.path = [""]; + // A string rather than a list of segments: that is what marks the + // path as opaque, so that "sc:" serializes back as "sc:" and a + // relative reference against it is rejected. + this.url.path = ""; this.state = "opaque path"; } } else if (!this.stateOverride) { @@ -1328,10 +1367,13 @@ class URLStateMachine { } this.atFlag = true; - // careful, this is based on buffer and has its own pointer (this.pointer != pointer) and inner chars - const len = countSymbols(this.buffer); - for (let pointer = 0; pointer < len; ++pointer) { - const codePoint = this.buffer.codePointAt(pointer); + // careful, this iterates over the buffer's code points, independently of + // this.pointer. Iterating the string itself rather than indexing it + // keeps a surrogate pair together; indexing by code unit while counting + // code points would read the low surrogate on its own and drop a + // character. + for (const codePointStr of this.buffer) { + const codePoint = codePointStr.codePointAt(0); if (codePoint === p(":") && !this.passwordTokenSeenFlag) { this.passwordTokenSeenFlag = true; @@ -1664,6 +1706,16 @@ class URLStateMachine { } else if (c === p("#")) { this.url.fragment = ""; this.state = "fragment"; + } else if (c === p(" ")) { + this.parseError = true; + // Trailing spaces are the ones that would change the URL's meaning if + // they were dropped when it is re-parsed, so only those get encoded. + const remaining = this.input[this.pointer + 1]; + if (remaining === p("?") || remaining === p("#")) { + this.url.path += "%20"; + } else { + this.url.path += " "; + } } else { // TODO: Add: not a URL code point if (!isNaN(c) && c !== p("%")) { @@ -1679,7 +1731,6 @@ class URLStateMachine { } if (!isNaN(c)) { - // @ts-ignore this.url.path += utf8PercentEncodeCodePoint( c, isC0ControlPercentEncode,