Skip to content

Bump WebKit (oven-sh/WebKit#724 preview): URL host setters and URLPattern no longer turn IDNA-mapped delimiters into URL structure - #43883

Draft
robobun wants to merge 2 commits into
mainfrom
robobun/8e4b8820/url-set-host-single-idna-pass
Draft

robobun wants to merge 2 commits into
mainfrom
robobun/8e4b8820/url-set-host-single-idna-pass

Conversation

@robobun

@robobun robobun commented Sep 24, 2026 •

Copy link
Copy Markdown
Collaborator

Draft until oven-sh/WebKit#724 merges. WEBKIT_VERSION is its preview build, for CI on every platform. The merged commit replaces it before this leaves draft.

Problem

  • A url.hostname or url.host value can write the user name, path, query, fragment and port. On 1.4.3, hostname = "bad.c℀.good.com" (U+2100) on https://base.test/p gives https://bad.ca/c.good.com/p. Node.js v26.10.0 refuses the value.
  • URLPattern: new URLPattern({ hostname: "*.trusted.example" }).test({ hostname: "evil.example@a.trusted.example" }) is true. Node.js gives false.
  • The cause is appendEncodedHostname in WTF::URL::setHost (vendor/WebKit/Source/WTF/wtf/URL.cpp:496). It runs IDNA on the raw value, and the result is spliced into the URL string and parsed as URL structure. IDNA maps 17 BMP code points to text with a delimiter (℀ to a/c, @ to @). The pass also runs before percent-decoding: "ß%41.de" gives xn--a-4ka.de, the constructor gives xn--a-pfa.de.

Fix

  • [WTF] URL::setHost parses the raw host: IDNA-mapped delimiters no longer become URL structure WebKit#724 deletes that pass. The URL parser runs IDNA once, after percent-decoding, then rejects a forbidden host code point. No bun source changes.
  • Tests in the existing files: url.test.ts (33 values), urlpattern.test.ts (19 hostnames and the row above), pathToFileURL.test.ts (UNC hosts). Every expected value is from Node.js v26.10.0.
  • Verified: 45 tests fail on main's pin f20ce774. With this pin bun bd test on the three files gives 499 pass, and the URL suites show no new failure.
  • Self-reviewed: 13 concerns raised, 11 addressed. The two not taken are in Notes.

Background

Downsides

  • A non-ASCII hostname= costs what the constructor pays: +1.9% to +3.6% instructions and one to three more allocations. An ASCII value costs 13% less. Table in Notes.
  • Values that only the pass made work are now refused, as in Node.js: "[::1]" (U+FF11) set [::1].
Notes

Self-review, not taken. (1) Fold a host-state parse into oven-sh/WebKit#724: that PR stays a deletion so that it is quick to review, and names the rest as step 2. (2) Open no companion PR until oven-sh/WebKit#724 merges: this one is a draft, so a preview pin cannot merge, and it gives CI results for every platform.

Provenance and lineage. Found by differential tests against Node.js v26.10.0. No user reported it. The code is inherited from upstream WebKit, which still has it. bad.c℀.good.com is the value from HackerOne 678487 that Node.js refuses in url.parse() since nodejs/node#38631. bun's legacy url.parse() already has that guard (src/js/node/url.ts:392). The WHATWG setters and URLPattern did not. This completes #38013 and #39273, which removed the IDNA pre-pass in front of the constructor.

Reach. Plain IDN hosts and ASCII hosts give the same result before and after. The value must reach a setter as decoded text: searchParams.get("h") for ?h=bad.c%E2%84%80.good.com does. A raw header value does not, because header bytes decode as Latin-1 and the 17 code points are all above U+00FF. WPT url/resources/setters_tests.json (c48d58747e) has no row for this class: 274 of 278 pass before and after.

Concerns left to other work.

Unblocks. The draft #43872 drops the xn-- reject layer (hasAcceptableHost). Without that layer u.hostname = "é.%78n--a" sets xn--9ca.xn--a on the current pin. With this pin the parser percent-decodes first, sees é.xn--a, ToASCII fails, and the set is refused.

Measurements. Release build of 6d504dd98, relinked twice from the same link line with one object in front of libWTF.a: URL.cpp at 299c5323 and URL.cpp with the fix. Instructions are single-step counts on the main thread inside the native setter, JIT off, steady state. Allocator calls are hits on mi_malloc.

window value before after
hostname= other.example 5,918, 1 malloc 5,141, 1
bücher.example 13,739, 2 13,996, 3
אבג.example 21,813, 6 22,598, 9
host= with :8080 other.example 7,478, 1 6,553, 1
bücher.example 15,320, 2 15,420, 3
אבג.example 23,197, 6 23,810, 9
pathname= after one hostname= /one/two 5,231, 2 4,747, 2

The extra allocation is the parser's copy of its result, which the constructor also pays. For אבג.example two more come from bun's hasValidPunycodeHost, which now gets an 8-bit host and makes it 16-bit for ICU, twice per set. href was a 16-bit string after any host set and is 8-bit now (jscDescribe), which is why the later pathname= is cheaper. Binary: text 80,684,744 to 80,681,159 bytes (-3,585).


[policy-decision:webkit] gate passed · iteration 0 · 4 files touched

passes on PR (with fix)
Test-only change.

Debug/ASAN (expected pass):
$ bun bd test 'test/js/node/url/pathToFileURL.test.ts' 'test/js/web/url/url.test.ts' 'test/js/web/urlpattern/urlpattern.test.ts'
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test test/js/node/url/pathToFileURL.test.ts test/js/web/url/url.test.ts test/js/web/urlpattern/urlpattern.test.ts
bun test v1.4.3 (367d939d9)

test/js/web/url/url.test.ts:
(pass) url > URL throws [24.25ms]
(pass) url > ERR_INVALID_URL carries input and, when given, base [12.10ms]
(pass) url > should have correct origin and protocol [18.99ms]
(pass) url > blob urls [12.78ms]
(pass) url > leaves opaque (non-special-scheme) hosts unchanged [13.69ms]
(pass) url > special-scheme hosts use the Unicode 16 IDNA table [7.74ms]
(pass) url > rejects invalid punycode labels however they are spelled in the input (like Node) [36.39ms]
(pass) url > judges literal punycode labels like Node (fast path and ICU path) [79.80ms]
(pass) url > host and hostname setters parse the value once, like the constructor > "other.example" [17.11ms]
(pass) url > host and hostname setters parse the value once, like the constructor > "Bücher.example" [4.82ms]
(pass) url > host and hostname setters parse the value once, like the constructor > "א.example" [4.88ms]
(pass) url > host and hostname setters parse the value once, like the constructor > "example。com" [3.66ms]
(pass) url > host and hostname setters parse the value once, like the constructor > "%C3%9F.de" [4.11ms]
(pass) url > host and hostname setters parse the value once, like the constructor > "x­" [3.54ms]
(pass) url > host and hostname setters parse the value once, like the constructor > "­" [2.35ms]
(pass) url > host and hostname setters parse the value once, like the constructor > "ß%41.de" [3.97ms]
(pass) url > host and hostname setters parse the value once, like the constructor > "ü%41.de" [4.25ms]
(pass) url > host and hostname setters parse the value once, like the constructor > "%C3%BCü.de" [3.89ms]
(pass) url > host and hostname setters parse the value once, like the constructor > "ß%2e.de" [4.33ms]
(pass) url > host and hos
... (truncated)
Exit: 0
diff hotspot
scripts/build/deps/webkit.ts              |  2 +-
 test/js/node/url/pathToFileURL.test.ts    | 10 ++++
 test/js/web/url/url.test.ts               | 97 +++++++++++++++++++++++++++++++
 test/js/web/urlpattern/urlpattern.test.ts | 54 +++++++++++++++++
 4 files changed, 162 insertions(+), 1 deletion(-)

gate history · 2 passed · 0 rejected · iteration 0

evidence per changed file
file                                       reads  edits  tests
scripts/build/deps/webkit.ts                   1      1     25
test/js/node/url/pathToFileURL.test.ts         0      0     14
test/js/web/url/url.test.ts                    2      1     18
test/js/web/urlpattern/urlpattern.test.ts      1      2     19

@robobun

robobun commented Sep 24, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status

Reproduced on 1.4.3-canary.1+6d504dd98 (linux x64), with Node.js v26.10.0 as the reference:

const u = new URL("https://base.test/p");
u.hostname = "bad.c\u2100.good.com";
u.href; // bun: "https://bad.ca/c.good.com/p"   node: "https://base.test/p" (refused)

new URLPattern({ hostname: "*.trusted.example" }).test({ hostname: "evil.example\uFF20a.trusted.example" });
// bun: true   node: false

const v = new URL("https://example.com:444/p");
v.hostname = "\u00DF%41.de";
v.host; // bun: "xn--a-4ka.de:444"   node and new URL(): "xn--a-pfa.de:444"

bun bd test test/js/web/url/url.test.ts test/js/web/urlpattern/urlpattern.test.ts test/js/node/url/pathToFileURL.test.ts gives 45 fail on WebKit f20ce774 (no fix) and 499 pass with this PR's pin.

The fix is oven-sh/WebKit#724. It merges cleanly, its CI is green, and it waits for a maintainer. This PR is its bun side (pin and tests) and stays a draft until that merges.

…the constructor

Rows for oven-sh/WebKit#724, which deletes the IDNA pass that WTF::URL::setHost
and setHostAndPort ran on the raw value before the URL parser saw it.

- url.test.ts: 33 values through hostname=, host=, host= with a port and the
  constructor: non-ASCII with %XX, with tab or newline, the code points that
  IDNA maps to a delimiter (U+2100, the fullwidth forms), IPv6 literals with a
  fullwidth digit. Five special schemes. href stays an 8-bit string.
- urlpattern.test.ts: 19 hostnames through the pattern constructor and exec()
  input, and a dictionary input that maps to text with '@'.
- pathToFileURL.test.ts: a UNC host with a tab or a %XX escape.

Every expected value is what Node.js v26.10.0 gives. 45 of these tests fail on
WebKit 299c5323. Some rows come from #33757.
oven-sh/WebKit#724 deletes the IDNA pass that WTF::URL::setHost and
setHostAndPort ran on the raw value. The preview pin is for CI only. It becomes
the merged oven-sh/WebKit commit before this PR leaves draft.
@robobun
robobun force-pushed the robobun/8e4b8820/url-set-host-single-idna-pass branch from e826196 to c331329 Compare September 27, 2026 11:33
@robobun

robobun commented Sep 27, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 5:07 AM PT - Sep 27th, 2026

✅ @robobun, your commit c331329740d302a1dfd040e3ea6253b26dfa1e2e passed in Build #121226! 🎉


🧪   To try this PR locally:

bunx bun-pr 43883

That installs a local version of the PR into your bun-43883 executable, so you can run:

bun-43883 --bun

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant