Skip to content

[WTF] URL::setHost parses the raw host: IDNA-mapped delimiters no longer become URL structure - #724

Open
robobun wants to merge 1 commit into
mainfrom
robobun/8e4b8820/url-set-host-single-idna-pass
Open

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

Conversation

@robobun

@robobun robobun commented Sep 24, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • A host value can write the user name, path, query, fragment and port. On https://base.test/p, setHost("bad.c℀.good.com") (U+2100) gives https://bad.ca/c.good.com/p. In Bun 1.4.3 this is url.hostname=, url.host= and URLPattern hostnames. Node.js refuses the value.
  • The cause is appendEncodedHostname (Source/WTF/wtf/URL.cpp:496). It runs uidna_nameToASCII 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.
  • The pass also runs before percent-decoding: "ß%41.de" gives xn--a-4ka.de, a URL string gives xn--a-pfa.de. "\tß.de" fails.

Fix

  • Delete appendEncodedHostname. Both setters splice the raw host text. URLParser::parseHostAndPort then runs IDNA once, after percent-decoding, and rejects a forbidden host code point, as for a URL string.
  • Verified: new WTF_URL.URLSetHost and URLSetHostAndPort (25 values, 8-bit and 16-bit) fail with the stock f20ce774 libWTF.a and pass with this URL.cpp. WTF_URLParser passes.
  • Step 2, not here: an empty or tab-only value on a special URL still moves the first path segment into the host. See Notes.

Background

Downsides

  • A non-ASCII value costs what the constructor pays: one more allocation (two over 256 bytes) and +7.7% to +29% instructions in URL::setHost. ASCII values cost 10% to 17% less. Table in Notes.
  • Values that only the pre-pass made work now fail: "[::1]" (U+FF11) set [::1].
Notes

Lineage. This completes oven-sh/bun#38013 and WebKit#39273, which removed the other IDNA pre-pass, in front of the constructor. The setters were outside that differential. Upstream added the @ and : checks in setHost / setHostAndPort in 270425 ("URL::setHostAndPort() needs to be aware of host delimiters"), 289609 and 289789. They look at the raw value, so the fullwidth forms pass them and become @ and : in the pre-pass. bad.c℀.good.com is the value from HackerOne 678487 that nodejs/node#38631 (70157b9cb7d7, v17.0.0) refuses in url.parse(). Bun's legacy url.parse() has that guard (src/js/node/url.ts:392). CPython had the same class as CVE-2019-9636. Upstream WebKit main still has appendEncodedHostname, so this deletion applies there too. It is worth a bugs.webkit.org report by someone with an account.

Reach. Plain IDN hosts and ASCII hosts give the same result before and after. Only three kinds of value change: non-ASCII with a %XX escape, non-ASCII with a tab or newline, and the code points that IDNA maps to a delimiter. The value must reach a setter as decoded text. A percent-encoded query parameter does (searchParams.get("h") with ?h=bad.c%E2%84%80.good.com gives https://bad.ca/c.good.com/p on 1.4.3). A raw header value does not: header bytes decode as Latin-1, and the 17 code points are all above U+00FF.

The 17 code points. A sweep of every non-ASCII BMP code point c as setHost("a" + c + "b.example") finds 17 where the setter and the parser disagree: U+2047, U+2048, U+2049, U+2100, U+2101, U+2105, U+2106, U+FE16, U+FE56, U+FE5F, U+FE68, U+FE6B, U+FF03, U+FF0F, U+FF1F, U+FF20, U+FF3C. With this change they agree on every row.

Step 2. The delimiter cut and the @ / : / [ checks still look at the raw value, before the parser removes tab and newline. So setHost("\t[::1]") is refused, and a value that is empty after the cut, or only tab and newline, leaves https:///p, which parses p as the host: setHost("/"), "#", "?", "\\" or "\t" on https://h/evil.example/p gives https://evil.example/p. This is the same before and after this PR. These host-state rules belong in URL::setHost and setHostAndPort too, for the reason given above against oven-sh/bun#33757: URLPattern does not go through bun's wrapper. Today oven-sh/bun#39458 (tab and newline) and oven-sh/bun#32826 ([::1]:80 through hostname=, default port, oven-sh/bun#28661) handle parts of it in URLDecomposition. This PR stays a deletion so that it is quick to review. It is the first step of any such change: a host-state parse also wants exactly one IDNA pass, inside the parser.

Unblocks. The bun draft oven-sh/bun#43872 drops bun's xn-- reject layer (hasAcceptableHost). Without that layer u.hostname = "é.%78n--a" sets xn--9ca.xn--a today, because the pre-pass converts é and the parser then sees only ASCII. With this change the parser percent-decodes first, sees é.xn--a, ToASCII fails, and the set is refused.

#643 changes the same lines (tryMakeString, no copy of an ASCII host into the Vector). After this PR only its makeString to tryMakeString edit remains for these two functions. The second one to land needs a small rebase.

bun side. oven-sh/bun#43883 (draft) pins the preview build of this PR and adds the tests: 45 fail on f20ce774, all 499 in the three files pass on the preview build (bun bd test, debug ASAN). bun's CI passed on every platform with the preview builds of cd049ba3 and 151f5eca (Buildkite builds 120269 and 121226). Re-checked on 0c06faad, after the upstream merge to dbdca7545d: this diff applies unchanged, the two new tests fail with the stock libWTF.a, and all 32 pass with this URL.cpp. The whole generated table of setter, URLPattern and constructor results is identical to Node.js v26.10.0 on that build.

Measurements. bun main 6d504dd98 release build (ThinLTO), relinked twice from the same link line with one object in front of libWTF.a: URL.cpp at 299c5323 and URL.cpp with this change. URL.cpp is the same file at f20ce774. Instructions are single-step counts on the main thread from the entry of the function to its return, JIT off, steady state (the calls give the same count). Allocator calls are hits on mi_malloc.

WTF::URL::setHost value before after
47-byte URL other.example 3,301, 1 malloc 2,725, 1
bücher.example 7,115, 2 7,663, 3
אבג.example 6,510, 2 7,353, 3
340-byte URL other.example 4,124, 1 3,417, 1
bücher.example 7,938, 2 8,546, 4
אבג.example 7,333, 2 8,550, 4
4 KB URL other.example 13,400, 1 11,966, 1
bücher.example 17,214, 2 17,931, 4
אבג.example 16,609, 2 21,396, 4

The extra allocations are the parser's: it sees a non-ASCII host, builds the result in its ASCII buffer (256 bytes inline) and copies it out. Before, the pre-pass handed it canonical text and it kept its input string. The URL string was 16-bit after any host set on a special URL and is 8-bit now, so later setters parse 8-bit text: bun's pathname= after one hostname= goes from 5,231 to 4,747 instructions. Binary: text 80,684,744 to 80,681,159 bytes (-3,585).

WPT. url/resources/setters_tests.json (c48d58747e) has no row for this class: bun passes 274 of 278 before and after, and the same 4 rows fail.

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Beyond the inline findings (both pre-existing, not introduced by this diff), I also checked the one behavior change on the refusal path: a special-scheme host that IDNA rejects now leaves an invalid URL with the raw spliced string instead of an untouched one. The Bun-side callers handle it — URLDecomposition::setHostname/setHost only commit when fullURL.isValid(), and URLPatternCanonical checks setHost's return value — so no caller ends up holding the raw text.

Extended reasoning...

The diff is a pure deletion in Source/WTF/wtf/URL.cpp (appendEncodedHostname and its uidna pre-pass removed from setHost and setHostAndPort, raw host now spliced and handed to parse()) plus new table-driven gtest rows in Tools/TestWebKitAPI/Tests/WTF/URL.cpp that CI does not run. It touches URL host parsing, a security-relevant surface (host/authority confusion), and the two confirmed inline findings are pre-existing gaps in the same functions rather than regressions from this change. The refusal-path behavior change was traced to its WebCore callers and is guarded by isValid()/return-value checks there.

Comment thread Source/WTF/wtf/URL.cpp Resolved
Comment thread Source/WTF/wtf/URL.cpp Resolved
@github-actions

github-actions Bot commented Sep 24, 2026 •

Copy link
Copy Markdown

Preview build of 151f5ec: autobuild-preview-pr-724-151f5eca

@coderabbitai

coderabbitai Bot commented Sep 24, 2026 •

Copy link
Copy Markdown

Review in Change Stack →

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Essentials

Run ID: 8ada694f-0b37-477d-9cbd-77d2571a16e3

📥 Commits

Reviewing files that changed from the base of the PR and between 35e8970 and cd049ba.

📒 Files selected for processing (2)
  • Source/WTF/wtf/URL.cpp
  • Tools/TestWebKitAPI/Tests/WTF/URL.cpp

Included review availability: Your plan provides up to 5 included reviews per hour; 1 remains after this review.


Walkthrough

Host setters now pass host text directly to URL parsing instead of applying local IDNA conversion. Tests cover Unicode and percent-encoded input, whitespace, delimiters, IPv6, opaque hosts, and port handling.

Changes

URL host setting

Layer / File(s) Summary
Pass host text to URL parsing
Source/WTF/wtf/URL.cpp
Removes the local hostname encoder. Both host setters pass host text directly to parsing.
Test host setter behavior
Tools/TestWebKitAPI/Tests/WTF/URL.cpp
Adds shared test coverage for both setters, including Unicode and percent-encoded input, whitespace removal, forbidden delimiters, IPv6 validation, opaque hosts, and port handling.

Priority: ⬇️ Low

Merge Risk: ⚪ Minimal · up to cd049

No actionable merge-blocking issue is established for the host-setter change; merge after normal checks.

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly identifies the raw-host parsing change and the delimiter issue it prevents.
Description check ✅ Passed The description thoroughly explains the problem, fix, tests, limitations, and performance impact. It does not include a Bugzilla issue link or the template’s explicit reviewer and changed-file section…

Warning

Git: CodeRabbit could not clone the repository, so clone-backed analysis was skipped and this review may be incomplete. Verify repository clone access, such as SSH credentials, before requesting another full review. If clone access is intentionally unavailable, use path_filters to narrow the review scope.


Comment @coderabbitai help to get the list of available commands.

robobun added a commit to oven-sh/bun that referenced this pull request Sep 24, 2026
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.
Both setters ran uidna_nameToASCII on the raw value (appendEncodedHostname),
spliced the result into the URL string and parsed that string. IDNA maps some
code points to text with an ASCII delimiter: the fullwidth forms of
/ \ ? # @ : %, U+2100 (a/c), U+2048 (?!) and others, 17 in the BMP. Mapped
before the splice, that text was parsed as URL structure:

    https://base.test/p  setHost("bad.c℀.good.com")  ->  https://bad.ca/c.good.com/p
    https://base.test/p  setHost("x@evil.example")   ->  https://x@evil.example/p
    https://base.test/p  setHost("a:81")             ->  https://a:81/p
    https://base.test/p  setHost("[::1]")            ->  https://[::1]/p

The '@' and ':' checks of 270425, 289609 and 289789 run on the raw value, before
the mapping, so these values passed them. The first value is the one Node.js
refuses in url.parse() since nodejs/node#38631. A URL string with the same host
text does not parse.

The pass also ran before the parser removed tab and newline and before it
percent-decoded: setHost("ß%41.de") gave xn--a-4ka.de where the URL string gives
xn--a-pfa.de, and setHost("\tß.de") failed where the URL string parses.

Delete appendEncodedHostname. The setters splice the raw host text, after the
existing delimiter cut and the '@' and ':' checks, and
URLParser::parseHostAndPort does what it does for a URL string: one IDNA pass
after percent-decoding, then the forbidden host code point check. A host that
is not special already took this path.

The URL string also stays 8-bit. The helper always produced a 16-bit buffer, so
the string was 16-bit after any host set on a special URL.

Not changed: the cut and the checks still look at the raw value. A value that
is empty after the cut, or only tab and newline, on a special URL still makes
the parser read the host from the path, as before.
@robobun
robobun force-pushed the robobun/8e4b8820/url-set-host-single-idna-pass branch from cd049ba to 151f5ec Compare September 27, 2026 10:38

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code review found no issues

No high-confidence issues detected in this change.

robobun added a commit to oven-sh/bun that referenced this pull request Sep 27, 2026
…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.
robobun added a commit to oven-sh/bun that referenced this pull request Sep 27, 2026
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant