Skip to content

url: stop running url.parse() hostnames through the WHATWG host parser - #33460

Closed
robobun wants to merge 6 commits into
mainfrom
farm/ae70882c/url-parse-legacy-host-grammar
Closed

url: stop running url.parse() hostnames through the WHATWG host parser#33460
robobun wants to merge 6 commits into
mainfrom
farm/ae70882c/url-parse-legacy-host-grammar

Conversation

@robobun

@robobun robobun commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator

Fixes #24812

Repro

import * as nurl from "node:url";
const H = s => { try { return nurl.parse(s).hostname; } catch (e) { return "THROW:" + e.constructor.name; } };
console.log(JSON.stringify(["http://0x7f.1/", "http://0300.0250.0.01/", "http://2130706433/",
  "http://[::ffff:1.2.3.4]/", "http://192.168.1.256/", "http://1.2.3.4.5/", "http://h:8a/x"].map(H)));
node v26.3.0: ["0x7f.1","0300.0250.0.01","2130706433","::ffff:1.2.3.4","192.168.1.256","1.2.3.4.5","THROW:TypeError"]
bun 1.4.0   : ["127.0.0.1","192.168.0.1","127.0.0.1","::ffff:102:304","THROW:TypeError","THROW:TypeError","h"]

url.parse() also throws outright on a multi-host connection string, which node parses (#24812):

require("node:url").parse("mongodb://user:password@[fd34:b871:e6a7::1],[fd34:b871:e6a7::2]:27017/db");
// node: Url { host: '[fd34:b871:e6a7::1],[fd34:b871:e6a7::2]:27017', ... }
// bun:  TypeError: "http://[fd34:b871:e6a7::1],[fd34:b871:e6a7::2]" cannot be parsed as a URL.

Three user-visible breaks:

  • Anything that logs, compares, allow-lists, or re-emits url.parse(x).hostname / .host / .href sees a different host string than node and than the wire input. Hostname allow-lists can be bypassed or broken by the hex/octal/dword rewrite.
  • url.parse(hostHeader) with no try/catch has a reachable-input throw on any dotted name whose last label is out of range or extra (192.168.1.256, 1.2.3.4.5).
  • http://h:8a/x mis-splits into hostname h + pathname /:8a/x instead of throwing.

Cause

Url.prototype.parse pushed the hostname through new URL("http://" + hostname) for IDNA support. That runs the WHATWG host parser, which does far more than IDNA: it canonicalizes IPv4 and IPv6 hosts, and rejects hosts the legacy grammar accepts.

Node's legacy parser applies a pure IDNA ToASCII and never validates or canonicalizes an IP. It is not url.domainToASCII either: node's domainToASCII is defined in terms of the host parser (domainToASCII("0x7f.1") is "127.0.0.1"), so url.parse uses an internal toASCII instead. #33206 is currently making Bun's domainToASCII match node on that point, which is correct for that API and is why url.parse must not share it.

Separately, getHostname emitted the DEP0170 warning and continued. Node removed that warning and throws instead, as of v23.

Fix

  • Add a toASCII binding to NodeURL.cpp: UTS Bun v0.0.41 #46 ToASCII via the same ICU transcoder, with no host parsing, so IPv4/IPv6 hosts pass through untouched. domainToASCII and it now share one nameToASCII helper rather than two copies of the ICU call.
  • Url.prototype.parse uses toASCII, then applies node's spoofing guards: reject a hostname that IDNA mapped to empty or to a character that changes how the host is read (: spoofs the protocol, @ the auth, [ / ] fake IPv6). IPv6 hosts skip IDNA entirely and get the IPv6 variant of the guard.
  • getHostname throws ERR_INVALID_ARG_VALUE on an invalid port.
  • urlParse's catch patched input onto every error, only because the new URL() throw carried the wrong one. $ERR_INVALID_URL(url) now sets it directly, so the catch is gone; ERR_INVALID_ARG_TYPE no longer gets a spurious input.

Each guard is load-bearing: toASCII("ab\uFF1Acd") returns "ab:cd", so only forbiddenHostChars rejects it; toASCII("\u00AD") returns "", so only the empty check rejects it.

Verification

Diffed against node v26.3.0 in the same container, comparing all 12 Url properties plus thrown error code and message, over 36 hosts (hex/octal/dword/short IPv4, IPv6 forms, out-of-range and extra-label IPv4, invalid ports, IDNA, punycode, spoofing code points). Identical, including ERR_INVALID_ARG_VALUE's exact message.

test/js/node/url/: 201 pass. Two failures (pathToFileURL doesn't leak memory, URL.canParse > repeatedly called) reproduce unchanged on a build with src/ reverted — they are 1e5-iteration/GC tests timing out under debug+ASAN.

Test updates
  • test/js/node/url/url.test.ts: new coverage. 6 of its assertions fail without this diff.
  • test-url-parse-invalid-input.js, test-url-parse-format.js: re-synced the affected blocks to node v26.3.0, which dropped the DEP0170 expectations and removed the git+ssh://git@github.com:npm/npm parse case now that it throws.
  • url-parse-ipv6.test.ts: [:::1] and [::banana] parse in node, so they move to the valid table. Its spot checks used toMatchObject(expect.objectContaining(x)), which is vacuous in Bun (it passes on a wrong value) and was hiding wrong expectations for [::F], [::1]:1 and [0:0:0:1:2:3:4:5]; they now assert for real against node's values.

One known divergence is left in place, with its own root cause: node's host scanner strips tab/LF/CR before splitting the host, Bun's copy predates that, so url.parse("https://[\n::1]") still throws here.

@coderabbitai

coderabbitai Bot commented Jul 6, 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: 20 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: ca00ef55-ca90-4b21-9601-0953ced31804

📥 Commits

Reviewing files that changed from the base of the PR and between 3e4531d and 34eef26.

📒 Files selected for processing (8)
  • src/js/node/url.ts
  • src/jsc/bindings/NodeURL.cpp
  • test/js/node/test/parallel/test-url-parse-format.js
  • test/js/node/test/parallel/test-url-parse-invalid-input.js
  • test/js/node/url/url-parse-format.test.js
  • test/js/node/url/url-parse-invalid-input.test.js
  • test/js/node/url/url-parse-ipv6.test.ts
  • test/js/node/url/url.test.ts

Walkthrough

Adds a native toASCII binding to NodeURL.cpp and uses it in src/js/node/url.ts to validate hostnames during legacy url.parse, rejecting invalid IDNA output with ERR_INVALID_URL. Removes lenient invalid-port warning behavior, replacing it with an immediate ERR_INVALID_ARG_VALUE throw. Updates related tests accordingly.

Changes

URL host/port validation tightening

Layer / File(s) Summary
Native toASCII binding
src/jsc/bindings/NodeURL.cpp
Adds shared nameToASCII helper and jsToASCII host function, refactors jsDomainToASCII to reuse it, and exports toASCII from createNodeURLBinding.
Legacy url.parse hostname and port validation
src/js/node/url.ts
Imports toASCII, adds forbiddenHostChars/forbiddenHostCharsIpv6 regexes, replaces URL-based hostname normalization with toASCII-based validation, removes try/catch wrapping in urlParse, and makes getHostname throw ERR_INVALID_ARG_VALUE immediately on invalid port colon.
Test updates for host validation
test/js/node/test/parallel/test-url-parse-format.js, test/js/node/test/parallel/test-url-parse-invalid-input.js, test/js/node/url/url-parse-format.test.js, test/js/node/url/url-parse-invalid-input.test.js, test/js/node/url/url-parse-ipv6.test.ts, test/js/node/url/url.test.ts
Updates tests to expect throws with ERR_INVALID_ARG_VALUE/ERR_INVALID_URL instead of deprecation warnings, tightens IPv6/generic parse assertions, removes the git+ssh npm URL fixture, and adds new host-parsing behavior and error-code coverage.

Sequence Diagram(s)

sequenceDiagram
  participant Caller
  participant UrlParse as Url.prototype.parse
  participant ToASCII as toASCII binding
  Caller->>UrlParse: parse(input)
  UrlParse->>UrlParse: parseHost()
  alt hostname is IPv6
    UrlParse->>UrlParse: check forbiddenHostCharsIpv6
  else hostname is not IPv6
    UrlParse->>ToASCII: toASCII(hostname)
    ToASCII-->>UrlParse: ASCII string or empty
    UrlParse->>UrlParse: check empty or forbiddenHostChars
  end
  UrlParse-->>Caller: parsed Url or throw ERR_INVALID_URL
  UrlParse->>UrlParse: getHostname() encounters ':'
  UrlParse-->>Caller: throw ERR_INVALID_ARG_VALUE
Loading

Related Issues: None referenced.

Related PRs: None referenced.

Suggested labels: node.js, url, needs-review

Suggested reviewers: None determinable from provided information.

🐰 A hostname twisted, colons astray,
Now bindings whisper "ASCII" the safer way,
No more warnings whispered soft and low,
Just a throw where danger used to grow,
Bun's burrow guards each parsed URL today.

🚥 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 and concisely summarizes the main change to legacy url.parse hostname handling.
Description check ✅ Passed The description covers the problem, root cause, fix, and verification, so it is mostly complete despite not using the exact template headings.

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

@robobun

robobun commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 4:06 PM PT - Jul 6th, 2026

@robobun, your commit 34eef26 has some failures in Build #69179 (All Failures)


🧪   To try this PR locally:

bunx bun-pr 33460

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

bun-33460 --bun

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

robobun commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 6, 2026

Copy link
Copy Markdown
Contributor
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@github-actions

github-actions Bot commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

Found 1 issue this PR may fix:

  1. Bun has different behavior with legacy require(node:url).parse #24812 - Reports that url.parse() throws on legacy-valid hostnames (e.g. MongoDB multi-host IPv6 connection strings) because Bun runs them through the WHATWG host parser instead of pure IDNA toASCII — exactly the root cause this PR fixes.

If this is helpful, copy the block below into the PR description to auto-close this issue on merge.

Fixes #24812

🤖 Generated with Claude Code

@robobun

robobun commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator Author

Good catch on #24812 — confirmed, and the stack trace in that issue points at the exact line this PR removes (new URL("http://" + this.hostname).hostname). The reporter even identified the right fix, linking node's toASCII call at lib/url.js#L412.

Verified against node v26.3.0 in the same container:

$ node --no-deprecation mongo.js
{ protocol: 'mongodb:', auth: 'user:password',
  host: '[fd34:b871:e6a7::1],[fd34:b871:e6a7::2]:27017', port: '27017',
  hostname: 'fd34:b871:e6a7::1],[fd34:b871:e6a7::2', pathname: '/db', ... }

$ bun mongo.js          # released
TypeError: "http://[fd34:b871:e6a7::1],[fd34:b871:e6a7::2]" cannot be parsed as a URL.

$ bun-debug mongo.js    # this PR
{ ...identical to node, including the malformed hostname... }

Added Fixes #24812 and folded that reproduction into the PR body, plus a test for it in 3e4531d. It covers a path the other cases don't: the IPv6 branch skipping IDNA entirely and passing the relaxed forbiddenHostCharsIpv6 guard with commas and two bracket groups in the host.

Kept the test in test/js/node/url/url.test.ts rather than test/regression/issue/: the new URL() IDNA line dates back to the original url.ts, so this never worked in Bun and isn't a regression.

Comment thread test/js/node/url/url-parse-ipv6.test.ts Outdated

@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.

Actionable comments posted: 1

Caution

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

⚠️ Outside diff range comments (1)
test/js/node/url/url-parse-invalid-input.test.js (1)

7-7: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

The updated assertions are unreachable: they live inside a test.todo(...) block that also references an undefined common global.

The whole body (lines 7-112) is wrapped in test.todo("invalid input", () => {...}), and it uses common.hasIntl / common.spawnPromisified (line 54, 99) even though common is never imported in this file (only describe/test from bun:test, assert, and url are imported). That means the new assert.throws(() => url.parse(badURL), { code: "ERR_INVALID_ARG_VALUE" }) assertions added here never actually execute, so this edit provides no real coverage of the new behavior. Equivalent, working coverage for these same cases already exists in test/js/node/url/url.test.ts (it.each cases for "https://evil.com:.example.com" and "git+ssh://git@github.com:npm/npm"), so the dead code here should either be removed or the .todo should be resolved now that this PR implements the exact behavior the // TODO: Support error code. comment calls out.

Based on learnings/coding guidelines: "Un-skip .todo tests your fix makes pass."

Also applies to: 90-93, 106-110

🤖 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 `@test/js/node/url/url-parse-invalid-input.test.js` at line 7, The new
invalid-URL assertions are currently unreachable because the block is wrapped in
test.todo and the file also references an undefined common global. Update the
url.parse invalid-input test so the cases in the invalid input suite actually
run, either by converting test.todo("invalid input", ...) to an active test or
by moving the working assertions into an existing non-todo test, and remove/fix
the common.hasIntl and common.spawnPromisified usages by importing or replacing
them appropriately. Use the invalid input test name and the url.parse assertions
as the anchor points when making the change.

Source: Coding guidelines

🤖 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.

Inline comments:
In `@src/jsc/bindings/NodeURL.cpp`:
- Around line 7-8: The IDNA error-mask is duplicated between the new file-scope
constant and the local constant in jsDomainToUnicode, so consolidate them into
one shared file-scope constant. Rename the shared mask to something like
allowedNameToIDNAErrors in NodeURL.cpp, then update both nameToASCII and
jsDomainToUnicode to use it instead of maintaining separate copies. Keep
hostnameBufferLength hoisted as-is and remove the redundant local
allowedNameToUnicodeErrors definition.

---

Outside diff comments:
In `@test/js/node/url/url-parse-invalid-input.test.js`:
- Line 7: The new invalid-URL assertions are currently unreachable because the
block is wrapped in test.todo and the file also references an undefined common
global. Update the url.parse invalid-input test so the cases in the invalid
input suite actually run, either by converting test.todo("invalid input", ...)
to an active test or by moving the working assertions into an existing non-todo
test, and remove/fix the common.hasIntl and common.spawnPromisified usages by
importing or replacing them appropriately. Use the invalid input test name and
the url.parse assertions as the anchor points when making the change.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 887c2896-4519-4872-8e6f-63d2c6285c1b

📥 Commits

Reviewing files that changed from the base of the PR and between 48ff9eb and 3e4531d.

📒 Files selected for processing (8)
  • src/js/node/url.ts
  • src/jsc/bindings/NodeURL.cpp
  • test/js/node/test/parallel/test-url-parse-format.js
  • test/js/node/test/parallel/test-url-parse-invalid-input.js
  • test/js/node/url/url-parse-format.test.js
  • test/js/node/url/url-parse-invalid-input.test.js
  • test/js/node/url/url-parse-ipv6.test.ts
  • test/js/node/url/url.test.ts
💤 Files with no reviewable changes (2)
  • test/js/node/url/url-parse-format.test.js
  • test/js/node/test/parallel/test-url-parse-format.js

Comment thread src/jsc/bindings/NodeURL.cpp Outdated
@robobun

robobun commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator Author

Review round addressed, all three findings were fair:

Dead process.emitWarning override (a6431a7) — it only existed to swallow DEP0170, which this PR deletes. Confirmed src/js/node/url.ts has zero emitWarning/DEP0170/DEP0169 references now, and it was the only such monkey-patch under test/js/node/url/. Re-ran with stderr captured to be sure nothing started warning once the suppressor was gone.

Duplicate IDNA error mask (240b928) — the two masks were byte-identical for the same reason, so there is now one allowedIDNAErrors with a comment saying why those errors are ignored (the WHATWG URL Standard turns off CheckHyphens and VerifyDnsLength; ICU has no option for either). Checked the file-scope hoist is safe first: src/jsc/bindings compiles as concatenated UnifiedSource-*.cpp, so a file-scope name can collide with another file in the same chunk. Nothing else in NodeURL's chunk (NodeFetch, NodeHTTP, NodeTLS, NodeVM, …) defines either name, and file-scope static constexpr is already used ~130 times across these bindings. Verified domainToASCII/domainToUnicode output is unchanged by the consolidation.

url-parse-invalid-input.test.js was unreachable (240b928) — correct, and the better catch of the three. The whole file was test.todo and referenced an undefined common, so my earlier edit to it was worthless. Its // TODO: Support error code. is precisely what this branch implements, so I rewrote it to actually run, following the convention of its sibling mirrors (url-relative.test.js et al: no common, plain bun:test + node:assert). It is now 6 real tests, 4.2s.

One deliberate narrowing there: the badIDNA sweep walks 0x80..0x110000 doing NFKD on every code point. That is ~1.7s in node's release build, but it ran for over ten minutes under bun bd without finishing, so it cannot live in the test runner. The bun test mirror asserts a representative slice (U+2100, U+FF20, U+FF1A, U+FF0F, U+FF03, U+FF1F — all verified to map to a forbidden character and throw, matching node), and the exhaustive sweep stays in test/js/node/test/parallel/test-url-parse-invalid-input.js, which runs in CI without the 5s timeout.

test/js/node/url/ is now 208 pass / 8 todo (was 202 / 9). The two remaining failures (pathToFileURL doesn't leak memory, URL.canParse > repeatedly called) reproduce identically on a build with src/ reverted — they are a 1e5-iteration loop and a GC leak test timing out under debug+ASAN.

Comment thread src/js/node/url.ts
@robobun

robobun commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator Author

CI status on fd0e934: the red lanes are queue starvation, not this diff

Build #68937 has been running for ~2h. Every failing status has the description Expired, and all 20 of them were posted inside an 8 second window:

14:05:31  failure  buildkite/bun/linux-x64-musl-build-bun        Expired
14:05:32  failure  buildkite/bun/windows-x64-build-cpp           Expired
14:05:32  failure  buildkite/bun/freebsd-x64-build-cpp           Expired
14:05:33  failure  buildkite/bun/linux-x64-build-cpp             Expired
14:05:33  failure  buildkite/bun/linux-x64-asan-build-rust       Expired
...

Expired means the status context timed out while its job sat unclaimed in the queue. The Buildkite job API confirms it: zero failed jobs, zero expired jobs, 244 still waiting for an agent. Nothing ran and failed. The affected lanes span freebsd, windows-aarch64, and android, which this diff cannot influence.

The C++ compile jobs that did get an agent all passed, and those are the ones that build the new nameToASCII / jsToASCII in src/jsc/bindings/NodeURL.cpp:

passed   :darwin: aarch64 - build-cpp
passed   :darwin: x64     - build-cpp
passed   :linux:  x64-asan - build-cpp
passed   :linux:  aarch64-musl - build-cpp
passed   :linux:  x64-musl - build-cpp
passed   :linux:  x64-musl-baseline - build-cpp
passed   :linux:  aarch64-android - build-cpp
passed   :linux:  x64-android - build-cpp
passed   :darwin: x64     - build-bun      (full link)

So the binding compiles on 8 platforms including ASAN, and the lanes showing red are duplicates of those that never got scheduled.

Deliberately not re-triggering. A new build would cancel the 24 green jobs and 244 in-flight ones and enqueue another ~280 onto the same saturated fleet, which is what caused the starvation in the first place. This branch pushed five commits in 75 minutes and each one cancelled its predecessor's build, so some of that pile-up is self-inflicted. Better to let #68937 drain.

Locally on fd0e934: test/js/node/url/ is 208 pass / 8 todo, the vendored node url tests are 20/21 (the one failure, test-url-format-invalid-input.js, fails identically on released Bun and is a node:test harness limitation), and the 36 host parity matrix against node v26.3.0 is byte identical.

robobun added 5 commits July 6, 2026 15:17
Legacy url.parse() mapped every hostname through `new URL("http://" + hostname)`
for IDNA support. That ran the WHATWG host parser, which canonicalizes IPv4 and
IPv6 hosts and rejects hosts the legacy grammar accepts.

Node applies a pure IDNA ToASCII to the hostname instead, then guards against
the mapping introducing a character that changes how the host is read. Add a
`toASCII` binding that does the UTS #46 mapping without host parsing, and port
node's forbidden-character guards. Also throw on an invalid port, which node
does as of v23 in place of the DEP0170 warning.
Both ICU call sites ignore the same error set, for the same reason: the
WHATWG URL Standard turns off CheckHyphens and VerifyDnsLength.

url-parse-invalid-input.test.js was entirely test.todo and referenced an
undefined `common`, so it never ran. Its "TODO: Support error code" is what
this branch implements, so rewrite it to run. The full badIDNA sweep stays in
the parallel/ copy: it walks the whole code point range and takes minutes
under a debug build.
node passes the reason where ERR_INVALID_ARG_VALUE expects the value, so the
message it prints reads oddly. Assert it verbatim, and say so at the throw, to
keep the argument order from being "corrected" into a message node never emits.
@robobun
robobun force-pushed the farm/ae70882c/url-parse-legacy-host-grammar branch from fd0e934 to 55a8401 Compare July 6, 2026 15:23
@robobun

robobun commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator Author

Rebased onto main (55a8401); the one real failure was a stale base, not this diff

Following up on my previous comment: most of #68937's red was status contexts expiring while their jobs sat unclaimed, but one job did eventually run and fail for real, so that comment was incomplete.

:darwin: 14 x64 - test-bun failed on three assertions in test/js/bun/cookie/cookie-map.test.ts:

-   "__Secure-id=; Path=/; Expires=Fri, 1 Jan 1970 00:00:00 -0000; Secure; SameSite=Lax"
+   "__Secure-id=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT; Secure; SameSite=Lax"

That is #32926 (Fix Bun.Cookie Expires to emit an IMF-fixdate) landing the emitter change and updating cookie.test.ts and cookie.test.js, but only two of the assertions in cookie-map.test.ts, leaving three behind in the delete with prefixed cookie names block. The stale expectation is wrong on its own terms, incidentally: 1 Jan 1970 was a Thursday, so Thu, 01 Jan 1970 00:00:00 GMT is the correct IMF-fixdate and the emitter is right.

Nothing to do with node:url. This branch never touched that file, and it reproduced locally on Linux with the failing build, so it was not darwin-specific either.

It was already fixed on main by #33425 (cookie: update remaining cookie-map Expires assertions to IMF-fixdate, 48ff9eb), which landed one commit after this branch's base. So the branch was simply stale. Rebased the five commits onto current main with git rebase --onto 48ff9eb2fd 9f29e55123 (the plain rebase misbehaves here because the checkout is shallow and the fork point cannot be derived).

After the rebase:

test/js/bun/cookie/cookie-map.test.ts   33 pass, 0 fail   (was 3 fail)
test/js/node/url/                      209 pass, 8 todo
node v26.3.0 host parity matrix        byte identical

The two remaining test/js/node/url/ failures (pathToFileURL doesn't leak memory, URL.canParse > repeatedly called) are the same pre-existing debug+ASAN timeouts as before, a 1e5-iteration loop and a GC leak test, and they reproduce on a build with src/ reverted.

@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.

My earlier inline feedback is addressed and this pass found nothing new, but the scope — new C++ IDNA binding, changed url.parse() hostname semantics, DEP0170 warn→throw, and the spoofing-guard regexes — is security-adjacent enough that a maintainer should sign off.

Extended reasoning...

Overview

This PR replaces the WHATWG-host-parser detour in url.parse() with a pure UTS #46 toASCII binding, so hostnames are IDNA-mapped but not IP-canonicalized or WHATWG-validated — matching node's legacy grammar. Touches src/js/node/url.ts (hostname/port validation logic, new forbiddenHostChars guards, DEP0170 warning removed in favor of ERR_INVALID_ARG_VALUE throw), src/jsc/bindings/NodeURL.cpp (new nameToASCII helper + jsToASCII host function, consolidated allowedIDNAErrors mask), and six test files (new coverage plus re-syncing vendored node tests to v26.3.0 semantics). Fixes #24812.

Security risks

Hostname parsing is security-relevant. The PR description itself notes the pre-fix behavior could bypass hostname allow-lists via hex/octal/dword rewriting. The new forbiddenHostChars / forbiddenHostCharsIpv6 regexes are the load-bearing spoofing guards (: protocol spoof, @ auth spoof, [/] fake-IPv6). These mirror node's lib/url.js, and the PR verified byte-for-byte parity against node v26.3.0 across 36 hosts — but any error in these guards would be a security issue, so they deserve a second pair of eyes rather than bot-only approval.

Level of scrutiny

Medium-high. This is Node-compat code for a deprecated-but-still-widely-used API, with a new native binding and a lenient→strict behavioral change (invalid ports now throw instead of warn). The C++ side is small and follows the exact pattern of the existing jsDomainToASCII (same ICU transcoder, same error-mask, proper RETURN_IF_EXCEPTION after toWTFString). The JS side is a fairly direct port of node's own logic. Not a config tweak or mechanical change.

Other factors

  • Both of my earlier inline comments were resolved: the dead process.emitWarning monkey-patch was removed, and the $ERR_INVALID_ARG_VALUE argument order was shown to match node v26.3.0 verbatim (now pinned by an exact-message test).
  • Test coverage is strong: new targeted tests in url.test.ts, the previously-test.todo url-parse-invalid-input.test.js rewritten to actually run, and vacuous toMatchObject(expect.objectContaining(...)) assertions in url-parse-ipv6.test.ts tightened to real assertions.
  • One known divergence (https://[\\n::1] still throws) is documented in the PR body with its own root cause.
  • CI red on the latest push is queue-starvation Expired statuses per the author's analysis; the C++ compile jobs that did run all passed.

Given the security-adjacent surface and the new native binding, deferring to a human maintainer rather than auto-approving.

Build 69089 went red on two infrastructure failures with no test or compile
failure behind them: linux x64-baseline build-cpp expired without ever being
assigned an agent (cascading 42 dependents), and darwin 26 aarch64 test-bun
aborted on a 120s buildkite-agent artifact download timeout before running a
single test. 238 jobs passed.
@robobun

robobun commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator Author

Build 69089: 238 passed, zero test or compile failures, two infrastructure aborts

Final tally on the rebased head:

passed 238
jobs with a nonzero exit 1
expired (never assigned an agent) 1
blocked on the expired job 42

Neither red mark is a test or compile failure.

:linux: x64-baseline - build-cpp expired with started_at: null, i.e. no agent ever picked it up. That cascaded its 42 dependents (build-bun, verify-baseline, and 40 debian 13 x64-baseline test-bun shards) into waiting_failed, so they can never run on this build.

:darwin: 26 aarch64 - test-bun is the only job with a nonzero exit, and it ran no tests at all:

Error: buildkite-agent artifact download timed out after 120s for step 'darwin-aarch64-build-bun'.
       Refusing to continue with a partial download
    at getExecPathFromBuildKite (scripts/runner.node.mjs:2182:13)

It aborted fetching the prebuilt binary before the runner started.

Meanwhile the jobs that compile the new nameToASCII / jsToASCII binding all passed, on every platform that got an agent:

passed  darwin aarch64 / darwin x64
passed  linux aarch64 / x64 / x64-asan / aarch64-musl / x64-musl / x64-musl-baseline
passed  linux aarch64-android / x64-android
passed  freebsd x64 / aarch64
passed  windows x64 / x64-baseline / aarch64
expired linux x64-baseline          <- never ran

15 of 16. Note that windows x64-baseline and linux x64-musl-baseline both passed, so the missing lane says nothing about baseline builds of this diff.

Pushed a single ci: retrigger (34eef26) to give both jobs another shot now that the build has drained. That is the one re-roll; if it lands on infrastructure again I will leave it red rather than keep pushing, and this comment is the record.

@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.

No bugs found and both earlier inline comments are addressed — deferring because this changes what hostnames url.parse() accepts/rejects (loosening validation to match node's legacy grammar), which is security-adjacent enough to warrant a human sign-off.

Extended reasoning...

Overview

This PR replaces the WHATWG-host-parser step in Url.prototype.parse with a pure UTS #46 toASCII binding, so url.parse() no longer canonicalizes/validates IPv4/IPv6 hosts and matches node's legacy grammar. It touches:

  • src/js/node/url.ts — swaps new URL("http://" + hostname).hostname for toASCII(hostname) + forbiddenHostChars/forbiddenHostCharsIpv6 guards; getHostname now throws ERR_INVALID_ARG_VALUE on invalid ports (was DEP0170 warning); drops the urlParse try/catch since $ERR_INVALID_URL sets .input itself (verified in ErrorCode.cpp:2340-2347).
  • src/jsc/bindings/NodeURL.cpp — extracts a shared nameToASCII helper, adds jsToASCII, refactors jsDomainToASCII to reuse it, consolidates the IDNA error mask.
  • Six test files re-synced/tightened, plus new coverage in url.test.ts including the #24812 mongodb multi-host repro and a pinned error-message assertion.

Security risks

URL hostname parsing is security-adjacent: user code uses url.parse().hostname for allow-listing, logging, and re-emission. This change loosens what the legacy parser accepts (e.g. [:::1], [::banana], 192.168.1.256, 1.2.3.4.5, multi-host bracket groups now parse instead of throwing) and stops rewriting hex/octal/dword IPv4 forms. That is the correct node-compat behavior and the PR adds the same forbiddenHostChars spoofing guards node uses, but it is a deliberate accept/reject surface change on a legacy API that node itself flags as CVE-exempt. No new injection or memory-safety concerns spotted in the C++ — the new binding is a straightforward extraction of the existing ICU call with proper RETURN_IF_EXCEPTION after toWTFString.

Level of scrutiny

Medium-high. The diff is modest (~50 net LOC in src/) and closely mirrors node's lib/url.js, but it's a behavioral change to a parser whose output feeds security decisions, and it removes a validation layer (the WHATWG host parser) in favor of node's laxer legacy grammar. The author has been thorough — 36-host parity matrix vs node v26.3.0, per-platform CI analysis, and the known remaining divergence ([\n::1]) is called out — but a maintainer should confirm the accept/reject changes are the intended trade-off.

Other factors

Both of my earlier inline comments (dead process.emitWarning override; $ERR_INVALID_ARG_VALUE argument order) were addressed — the argument-order one turned out to be intentional node parity and is now pinned by a verbatim-message test with an explanatory comment. CodeRabbit's error-mask consolidation nit was also applied. CI on the latest push is pending (build #69179); prior builds were green modulo infrastructure expiries. Test coverage for the new behavior is solid, including the tightened toMatchObject assertions in url-parse-ipv6.test.ts that were previously vacuous.

@robobun

robobun commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator Author

On the accept/reject surface change, stated plainly

The bot review is right that this loosens what url.parse() accepts, and it is worth being explicit about which direction each change cuts, because it is not uniformly a tightening.

Now accepted, previously threw ERR_INVALID_URL

192.168.1.256, 1.2.3.4.5, 999.999.999.999, 0x100000000, [:::1], [::banana], and multi-host bracket groups (#24812).

Now returned verbatim, previously rewritten

0x7f.1, 0300.0250.0.01, 2130706433, 127.1, ::ffff:1.2.3.4, 0:0:0:0:0:0:0:1.

Now rejected, previously warned and continued

Invalid ports: http://h:8a/x, https://evil.com:.example.com, git+ssh://git@github.com:npm/npm now throw ERR_INVALID_ARG_VALUE.

Still rejected, and the guard is now explicit rather than incidental

Hostnames that IDNA-map into a character that changes how the host is read (U+FF20 to @, U+2100 to a/c, U+FF1A to :), hostnames that IDNA-map to nothing (U+00AD), and forbidden characters inside an IPv6 host. Previously the WHATWG parser happened to reject these; now forbiddenHostChars / forbiddenHostCharsIpv6 do, which is what node does.

The honest trade-off

It depends entirely on which way the caller's check runs.

For allow-lists, logging, and re-emission, the old behavior was the bug, and it is what prompted this report. url.parse(hostHeader).hostname returned a string that differed from the wire input, so a comparison against the received host silently failed or could be steered, and any dotted name with an out-of-range last label was a reachable-input throw in code with no try/catch.

For block-lists, i.e. SSRF guards, this is a real loosening and I do not want to paper over it. Before, url.parse("http://0x7f.1/").hostname normalized to 127.0.0.1, so a naive loopback blocklist caught it. After, it is 0x7f.1, which that blocklist will not match even though the host still resolves to loopback.

Three things make that the correct call anyway:

  1. It is exactly what node does, and node documents this API as not standardized, prone to errors with security implications, and explicitly CVE-exempt (DEP0169's own wording: "CVEs are not issued for url.parse() vulnerabilities"). Anyone running that guard on node already has the exposure. Bun being accidentally stricter is a compatibility bug, not a feature, and silently disagreeing with node about what a hostname is seems worse than agreeing with it.
  2. The right tool still works. new URL() canonicalizes http://0x7f.1/ to 127.0.0.1 and this PR does not touch the WHATWG URL implementation. The diff is two source files, src/js/node/url.ts and src/jsc/bindings/NodeURL.cpp.
  3. The anti-spoofing guards are preserved, and this PR is what makes them explicit instead of a side effect of a parser that was only ever there for IDNA.

So: deliberate, matches node byte for byte across the 36 host parity matrix, and the one behavior anybody should be relying on for a security decision is unchanged. Happy to gate any of it behind something else if a maintainer disagrees with the read.

@robobun

robobun commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator Author

Re-roll (build 69179): 281 passed, same darwin artifact-download flake, re-roll spent

The ci: retrigger landed on the identical infrastructure failure, and this build makes it unambiguous that it is not the diff.

:darwin: 26 aarch64 - test-bun runs as two shards against the same prebuilt binary. In this build:

passed  :darwin: 26 aarch64 - test-bun
failed  :darwin: 26 aarch64 - test-bun   <- exit 1, ran zero tests

The failed shard never started a test:

Error: buildkite-agent artifact download timed out after 120s for step 'darwin-aarch64-build-bun'.
       Refusing to continue with a partial download

And :darwin: aarch64 - build-bun passed (exit 0), so the artifact exists and is valid, its sibling shard downloaded the same one and ran green. One of two identical shards timing out fetching a binary that the other fetched fine is a storage/network flake, by definition not something this PR can cause. It is the only nonzero-exit job in the build; the other 281 passed.

This is the one sanctioned re-roll (34eef26) and it is now spent. I am not going to keep pushing ci: retrigger against a flaky S3 download, that would just be noise. The two jobs can be retried individually from the Buildkite UI with one click, and should go green.

Summary for a maintainer:

  • Diff is two source files (src/js/node/url.ts, src/jsc/bindings/NodeURL.cpp), byte-for-byte parity with node v26.3.0 across the 36-host matrix, test/js/node/url/ 209 pass locally, new binding compiles on every platform that got an agent.
  • No real test or compile failure has appeared in any build; every red mark across 68937/69089/69179 has been an Expired queue timeout or this 120s artifact-download timeout.
  • Both review bots converged with nothing outstanding; claude[bot] asked for a human sign-off on the deliberate accept/reject change (covered in the comment above).

Ready to merge or to have the two darwin shards retried.

@alii

alii commented Aug 12, 2026

Copy link
Copy Markdown
Member

landed in #34660, url.parse hostnames match node on main now

@alii alii closed this Aug 12, 2026
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.

Bun has different behavior with legacy require(node:url).parse

2 participants