Skip to content

url: reject invalid Punycode (xn--) labels in special-scheme hosts - #33201

Closed
robobun wants to merge 2 commits into
mainfrom
farm/fdf42f17/url-idna-ace-label-validation
Closed

url: reject invalid Punycode (xn--) labels in special-scheme hosts#33201
robobun wants to merge 2 commits into
mainfrom
farm/fdf42f17/url-idna-ace-label-validation

Conversation

@robobun

@robobun robobun commented Jul 1, 2026

Copy link
Copy Markdown
Collaborator

Bun accepts hosts with invalid Punycode labels that Node and the URL Standard's host parser reject:

new URL("http://xn--a-ecp.example/").hostname
// Bun:  "xn--a-ecp.example"
// Node: Uncaught TypeError: Invalid URL

xn--a-ecp decodes to U+0061 U+2488 (a followed by DIGIT ONE FULL STOP), which UTS-46 disallows in a domain label, so the spec's domain to ASCII step must fail. The same divergence exists in URL.canParse, URL.parse, the href/host/hostname setters, .origin on a blob: URL (Node gives "null"), and url.domainToASCII (which returns the input verbatim instead of the documented "").

This is a parser differential on the host, the security-relevant component of a URL: anything that validates a URL with Bun's URL class before a Node service or a browser fetches it (allowlists, SSRF filters, link sanitizers, webhook validators) disagrees with every conformant parser about whether the host is even valid.

Cause

WTF::URLParser::parseHostAndPort has an all-ASCII fast path (and a second one inside its own domainToASCII) that lowercases and appends the host without ever calling ICU, so xn-- labels are never decoded or validated. jsDomainToASCII in NodeURL.cpp has the same early return copied from it.

Fix

New src/jsc/bindings/BunIDNA.{h,cpp}:

  • Bun::domainHasACELabel(StringView): does any dot-separated label start with xn--
  • Bun::domainToASCII(StringView): UTS-46 domain to ASCII with the same ICU transcoder and allowed-error mask that URLParser::domainToASCII uses; null String on failure
  • Bun::urlHostIsValidIDNA(const URL&): for a parsed special-scheme URL whose host contains an ACE label, re-run domainToASCII and fail if it rejects

DOMURL::create, parseInternal, and setHref now require urlHostIsValidIDNA in addition to isValid(). The href, host, and hostname setters all funnel through DOMURL::setHref via URLDecomposition::setFullURL, so they are covered by the same check: href throws, and host/hostname become the silent no-op the spec requires. URLDecomposition::origin() applies the same check to the inner URL it re-parses out of a blob: URL's path, so an invalid inner host yields the opaque ("null") origin. jsDomainToASCII gates its fast path on !domainHasACELabel and routes ACE labels through the shared Bun::domainToASCII.

Only hosts that actually contain an xn-- label reach ICU. For every other all-ASCII host, UTS-46 with the URL spec's options is equivalent to the lowercasing the fast path already does, so nothing else changes. Opaque hosts of non-special schemes are untouched, matching Node. Bun::domainToASCII retries once with a heap buffer when ICU reports a buffer overflow, so a spec-valid host longer than ICU's 2048-code-unit stack buffer is not rejected.

Not covered

The fast path itself lives in WebKit's URLParser.cpp (vendor/WebKit, shipped prebuilt), so Request and fetch, which build a WTF::URL directly, still accept these hosts. Fixing that belongs in oven-sh/WebKit. This PR covers the URL class and node:url.

Verification

A 66-case matrix over the constructor, canParse, parse, the setters, and domainToASCII (invalid ACE labels, valid ACE labels like xn--bcher-kva.de and xn--e1afmkfd.xn--p1ai, case and percent-encoding variants, non-ACE hosts with the same hyphen patterns, IPs, opaque hosts) produces byte-identical output from this build and from Node 26. The two test files fail 21 of their assertions under the released Bun.

@coderabbitai

coderabbitai Bot commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 10 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 9a5a0101-6f45-4383-8b56-ec822e786135

📥 Commits

Reviewing files that changed from the base of the PR and between b920f2f and 696eb97.

📒 Files selected for processing (5)
  • src/jsc/bindings/BunIDNA.cpp
  • src/jsc/bindings/BunIDNA.h
  • src/jsc/bindings/DOMURL.cpp
  • src/jsc/bindings/NodeURL.cpp
  • src/jsc/bindings/URLDecomposition.cpp

Walkthrough

Adds Bun IDNA helpers for ACE-label detection, domain-to-ASCII conversion, and URL host validation. DOMURL now uses the new host check for URL validity, NodeURL routes domain-to-ASCII through the helper, and tests cover invalid and valid punycode labels.

Changes

IDNA validation feature

Layer / File(s) Summary
BunIDNA core helpers and header
src/jsc/bindings/BunIDNA.cpp, src/jsc/bindings/BunIDNA.h
New domainHasACELabel, domainToASCII, and urlHostIsValidIDNA functions detect punycode labels, convert domains to ASCII via ICU UTS-46, and validate URL hosts for special-scheme URLs.
DOMURL integration with isValidCompleteURL
src/jsc/bindings/DOMURL.cpp
New isValidCompleteURL helper combines url.isValid() with Bun::urlHostIsValidIDNA(url); used in DOMURL::create overloads, parseInternal, and setHref to reject invalid punycode hosts.
NodeURL domainToASCII rewrite
src/jsc/bindings/NodeURL.cpp
jsDomainToASCII now uses an ACE-aware ASCII fast path and calls the new domainToASCII helper instead of direct ICU buffer handling.
IDNA validation tests
test/js/node/url/url-domain-ascii-unicode.test.js, test/js/web/url/url.test.ts
New tests verify domainToASCII and URL constructor/canParse/parse/setter behavior for invalid and valid punycode labels across multiple schemes.

Related PRs: None specified.

Suggested labels: url, node-compat, needs-review

Suggested reviewers: None specified.

🐰

Punycode labels, checked with care,
ICU whispers if hosts are fair,
Invalid xn-- now turned away,
DOMURL and NodeURL align today.

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: rejecting invalid Punycode labels in special-scheme hosts.
Description check ✅ Passed The description covers the purpose and verification steps, though it uses custom headings instead of the template's exact section names.
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.

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

@github-actions github-actions Bot added the claude label Jul 1, 2026
@robobun

robobun commented Jul 1, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 5:59 PM PT - Jul 7th, 2026

@robobun, your commit 696eb97 has some failures in Build #70038 (All Failures)


🧪   To try this PR locally:

bunx bun-pr 33201

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

bun-33201 --bun

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/jsc/bindings/DOMURL.cpp (1)

82-88: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Validate the URL overload's base before resolving absolute inputs.

Line 86 only checks completeURL. When url is absolute, URL completeURL { base, url } ignores base, so this overload can still accept a base like http://xn--a-ecp.example/ even though Line 94 rejects the same base in the string overload. Please reject !isValidCompleteURL(base) first, otherwise this remains a bypass for the new IDNA validation.

Suggested fix
 ExceptionOr<Ref<DOMURL>> DOMURL::create(const String& url, const URL& base)
 {
     ASSERT(base.isValid() || base.isNull());
+    if (!base.isNull() && !isValidCompleteURL(base))
+        return Exception { InvalidURLError, makeString(redact(url), " cannot be parsed as a URL against "_s, redact(base.string())) };
     URL completeURL { base, url };
     if (!isValidCompleteURL(completeURL))
         return Exception { InvalidURLError, makeString(redact(url), " cannot be parsed as a URL."_s) };
     return adoptRef(*new DOMURL(WTF::move(completeURL)));
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/jsc/bindings/DOMURL.cpp` around lines 82 - 88, In DOMURL::create(const
String& url, const URL& base), validate the base URL with
isValidCompleteURL(base) before constructing completeURL, since absolute inputs
can bypass the base check by ignoring it during resolution. Add the rejection
path near the existing ASSERT and keep the current completeURL validation
afterward so both the base and resolved URL are enforced consistently with the
string overload.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@src/jsc/bindings/DOMURL.cpp`:
- Around line 82-88: In DOMURL::create(const String& url, const URL& base),
validate the base URL with isValidCompleteURL(base) before constructing
completeURL, since absolute inputs can bypass the base check by ignoring it
during resolution. Add the rejection path near the existing ASSERT and keep the
current completeURL validation afterward so both the base and resolved URL are
enforced consistently with the string overload.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 2410dd0f-ed16-4211-8de7-8d5107b75383

📥 Commits

Reviewing files that changed from the base of the PR and between eba370b and b920f2f.

📒 Files selected for processing (6)
  • src/jsc/bindings/BunIDNA.cpp
  • src/jsc/bindings/BunIDNA.h
  • src/jsc/bindings/DOMURL.cpp
  • src/jsc/bindings/NodeURL.cpp
  • test/js/node/url/url-domain-ascii-unicode.test.js
  • test/js/web/url/url.test.ts

@robobun
robobun force-pushed the farm/fdf42f17/url-idna-ace-label-validation branch from b920f2f to 3c2b2fc Compare July 1, 2026 19:53
@robobun

robobun commented Jul 1, 2026

Copy link
Copy Markdown
Collaborator Author

Pushed 3c2b2fc: rebased onto main, with autofix.ci's formatting change included.

On the finding about DOMURL::create(const String& url, const URL& base) not validating base: that overload is private (DOMURL.h), and its only caller is DOMURL::create(const String& url, const String& base), which rejects an invalid base one line earlier (DOMURL.cpp:94) before delegating. Every external entry point (JSDOMURL.cpp:165, BunObject.cpp:765, BunString.cpp:593) goes through that public string overload, so no path reaches the URL overload with an unvalidated non-null base.

The exact scenario described, an absolute input against a bad base, is covered by a test in this PR and throws:

expect(() => new URL("http://ok.example/", "http://xn--a-ecp.example/")).toThrow("cannot be parsed as a URL");

Rather than add an unreachable runtime check, I strengthened the existing precondition assert from ASSERT(base.isValid() || base.isNull()) to ASSERT(base.isNull() || isValidCompleteURL(base)), so the contract is explicit and any future internal caller that skips base validation trips in debug builds.

Comment thread src/jsc/bindings/BunIDNA.cpp Outdated
Comment thread src/jsc/bindings/BunIDNA.cpp

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Both earlier nits are addressed and I found nothing further, but this is a security-relevant behavioral change to new URL() host parsing that intentionally leaves fetch/Request on the old behavior — worth a human sign-off on that trade-off before merging.

Extended reasoning...

Overview

Adds src/jsc/bindings/BunIDNA.{h,cpp} with three helpers (domainHasACELabel, domainToASCII, urlHostIsValidIDNA) and wires them into DOMURL::create/parse/canParse/setHref, URLDecomposition::origin()'s blob inner-URL branch, and jsDomainToASCII in NodeURL.cpp. Net effect: special-scheme URLs whose host contains an invalid Punycode (xn--) label are now rejected, matching Node and the URL Standard. ~90 lines of new C++ plus ~120 lines of tests across two files.

Security risks

The change is about security — it closes a host-parser differential usable to bypass URL allowlists / SSRF filters that validate with Bun before a conformant parser fetches. The direction is strictly tightening (reject more), so the new code itself introduces no obvious new attack surface. The one subtlety is that the PR knowingly leaves WTF::URL (and therefore fetch/Request) on the permissive path, so within Bun URL.canParse(x) === false while fetch(x) still resolves the host — an intra-runtime inconsistency the description defers to an oven-sh/WebKit change.

Level of scrutiny

High. This is production URL host parsing, explicitly security-relevant, and a user-visible behavioral change to a core Web API (new URL() now throws on inputs it previously accepted). IDNA/UTS-46 semantics are subtle enough that a maintainer should confirm the ICU option set, the allowedNameToASCIIErrors mask reuse, and the decision to ship a partial fix ahead of the WebKit-side change.

Other factors

My two prior inline comments (ICU U_BUFFER_OVERFLOW_ERROR retry; blob: origin sibling site) were both fixed in 4b55c3e with tests, and the current bug-hunt pass found nothing. I verified the host/hostname setter no-op claim: DOMURL::setFullURL at DOMURL.h:78 routes through setHref, whose ExceptionOr is discarded, yielding the spec-required silent no-op. Test coverage is thorough (constructor/canParse/parse/setters/base-URL/blob-origin/non-special-scheme/overflow-retry, plus valid-ACE and non-ACE negative controls). The code looks correct to me; deferring purely because security-sensitive URL-parsing behavioral changes warrant a human reviewer per policy.

@robobun

robobun commented Jul 1, 2026

Copy link
Copy Markdown
Collaborator Author

Thanks for the review. Both earlier findings are fixed in 4b55c3e, and I agree the fetch/Request scope call deserves a human look, so to make that decision easy:

fetch and Request build a raw WTF::URL, and the permissive all-ASCII fast path lives in WTF::URLParser::parseHostAndPort inside the prebuilt bun-webkit artifact. Closing it there means a change in oven-sh/WebKit plus a WEBKIT_VERSION bump, which I did not want to couple to this PR. The differential this PR closes is the one that matters for validators: code that checks a URL with URL/URL.canParse and hands it to a conformant parser. A Bun fetch of one of these hosts just fails DNS; it is an intra-runtime inconsistency worth a WebKit follow-up, not a bypass.

CI status (build 70038, final)

284 jobs passed. The only failures are both shards of darwin 26 aarch64 - test-bun, which die on buildkite-agent artifact download timed out after 120s on agent darwin-aarch64-26.5.1-1 before any test runs; that lane has failed the same way on every build of this branch and across unrelated PRs. There are no style=error annotations; the only other annotation is style=warning ctx=flaky (retried-and-passed). No failure anywhere in the build references URL, IDNA, or punycode; the two test files this PR touches passed on every lane.

A retrigger cannot fix that agent, so I have not pushed one. This is ready to merge with that lane overridden or retried.

@robobun

robobun commented Jul 1, 2026

Copy link
Copy Markdown
Collaborator Author

Heads up on an overlap: #33206 rewrites jsDomainToASCII/jsDomainToUnicode in src/jsc/bindings/NodeURL.cpp to run the full WHATWG host parser the way Node does, which supersedes the domainHasACELabel gate this PR adds there (that gate covers xn-- labels but still returns a%b instead of "", leaves domainToUnicode unvalidated, and skips percent-decoding, IPv4, and case folding).

The URL-class half of this PR (BunIDNA, DOMURL, URLDecomposition) is a different surface and #33206 does not touch it. To avoid the conflict, the NodeURL.cpp and test/js/node/url/url-domain-ascii-unicode.test.js hunks can be dropped from this PR; #33206 carries them. Otherwise whichever lands second gets rebased.

robobun added 2 commits July 7, 2026 19:03
WTF::URLParser takes an all-ASCII fast path for special-scheme hosts
that never runs UTS-46 "domain to ASCII", so an "xn--" label that does
not decode to a valid IDNA label (for example "xn--a-ecp") parses
successfully. Node and the URL Standard's host parser reject it.

Add Bun::urlHostIsValidIDNA, which re-runs ICU's uidna_nameToASCII on
a parsed special-scheme host, but only when one of its labels starts
with "xn--"; for every other all-ASCII host the fast path is already
equivalent to the spec. Wire it into DOMURL's create, parse, canParse,
and setHref, which also covers the href, host, and hostname setters.

url.domainToASCII had the same all-ASCII early return; gate it on the
same predicate and route ACE labels through the shared helper.
… host

Bun::domainToASCII used a fixed 2048-code-unit buffer and treated
U_BUFFER_OVERFLOW_ERROR as "invalid IDNA", so an all-ASCII host longer
than that which happens to contain an "xn--" label was newly rejected.
Retry once with a heap buffer of the length ICU reports, following
ICU's preflight convention. The URL Standard puts no length limit on a
domain, and Node accepts these.

URLDecomposition::origin() re-parses a blob: URL's path with a raw
WTF::URL and only checked isValid(), so an invalid "xn--" label in the
inner host still produced a tuple origin. Apply the same host check so
the inner parse fails and the origin serializes to "null", as Node does.
@robobun

robobun commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator Author

The commits from this branch have been cherry-picked into #34731 so both IDNA fixes (the Unicode 16.0 mapping-table override and the all-ASCII xn-- fast-path bypass) land together. If #34731 merges this can be closed.

@robobun

robobun commented Aug 17, 2026

Copy link
Copy Markdown
Collaborator Author

Closing: this behaviour landed on main through #34660 (merged Aug 7), which rejects invalid xn-- labels in special-scheme hosts for the constructor, URL.parse, URL.canParse, href and the host/hostname setters (hasValidPunycodeHost in NodeURL.cpp, used from DOMURL.cpp and URLDecomposition.cpp), and returns "" from domainToASCII for them.

Checked by applying this PR's test additions to current main (5c050bc, ICU 78.3): the url-domain-ascii-unicode.test.js cases pass unchanged, and the url.test.ts cases pass once the expected message is Node's Invalid URL (main's message since #34660), with one exception: new URL("blob:http://xn--a-ecp.example/foo").origin still returns a tuple origin on main. That case is fixed by #39404.

The cases from here that main's tests did not already have (the decodable but disallowed xn--a-ecp label across the special schemes and the setters, axn-- style hosts, the ICU buffer overflow retry) are ported in #39415.

@robobun robobun closed this Aug 17, 2026
robobun added a commit that referenced this pull request Aug 17, 2026
…II and URL

Ports the test tables from the superseded #33201, #34731 and #33206 that
current tests did not cover: domainToASCII/domainToUnicode host parsing
(percent-decoding, IPv4/IPv6, authority terminators, tab and newline
stripping, invalid punycode), punycode labels that decode to disallowed
code points across every special scheme, the setters, hosts longer than
the IDNA conversion's initial buffer, and more of the Unicode 16 UTS #46
reclassifications. Also makes the domainToUnicode invalid-input loop call
domainToUnicode instead of domainToASCII.
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