Skip to content

url: make the hostname setter a no-op on ":" outside an IPv6 bracket - #36118

Closed
robobun wants to merge 6 commits into
mainfrom
farm/0aab15f3/url-hostname-setter-ipv6-port-leak
Closed

robobun wants to merge 6 commits into
mainfrom
farm/0aab15f3/url-hostname-setter-ipv6-port-leak

Conversation

@robobun

@robobun robobun commented Jul 27, 2026 •

Copy link
Copy Markdown
Collaborator

What

url.hostname = "[::1]:81" was writing both the host and the port. The hostname setter is the one place spec-guaranteed never to touch the port, so this silently retargeted requests.

const u = new URL("http://a/p");
u.hostname = "[::1]:81";
u.href;
// bun 1.4.0:  "http://[::1]:81/p"
// spec/Node:  "http://a/p"   (no-op)

Reproduces on any special scheme with a bracketed IPv6 literal followed by :<anything>, as long as the URL has no existing port (an existing port made the recomposed string unparsable, which accidentally no-opped).

Why

URLDecomposition::setHostname forwards to WTF URL::setHost, whose colon guard is:

if (newHost.contains(':') && !newHost.startsWith('['))
    return false;

A value that starts with [ bypasses the guard entirely, so [::1]:81 reaches the reparse of left(hostStart) + "[::1]:81" + substring(m_hostEnd) and :81 lands in the port slot.

Per the URL Standard's host state with state override = hostname state, a : outside the IPv6 brackets returns immediately without setting anything.

Fix

Scan the value in URLDecomposition::setHostname the way the spec state machine does: skip ASCII tab/newline, stop at the host terminators (/, ?, #, and \\ for special schemes) that URL::setHost already truncates at, and return early on the first : seen outside [ ... ].

Verification

18 new cases in test/js/web/url/url.test.ts, all cross-checked against Node: the 8 that previously leaked a port now no-op, and the 10 that were already correct (plain [::1], terminator-before-colon, non-bracketed h:81) are kept as regression guards.

USE_SYSTEM_BUN=1 bun test test/js/web/url/url.test.ts -t "hostname setter never writes the port"
  8 fail, 10 pass
bun bd test test/js/web/url/url.test.ts
  36 pass, 0 fail

[review] gate passed · iteration 2 · 2 files touched

fails on main (without fix)
ASAN without fix: 11 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/web/url/url.test.ts
bun test v1.4.0 (10f551cdf)

test/js/web/url/url.test.ts:
(pass) url > URL throws [19.81ms]
(pass) url > should have correct origin and protocol [15.45ms]
(pass) url > blob urls [8.84ms]
(pass) url > prints [11.21ms]
(pass) url > works [19.24ms]
222 |     ["http://a/p", "[::1]", "http://[::1]/p"],
223 |     ["http://a:1/p", "[::1]", "http://[::1]:1/p"],
224 |   ])("hostname setter never writes the port: new URL(%j).hostname = %j", (base, value, expected) => {
225 |     const url = new URL(base);
226 |     url.hostname = value;
227 |     expect({ href: url.href, port: url.port }).toEqual({ href: expected, port: new URL(expected).port });
                                                     ^
error: expect(received).toEqual(expected)

  {
-   "href": "http://a/p",
-   "port": "",
+   "href": "http://[::1]:81/p",
+   "port": "81",
  }

- Expected  - 2
+ Received  + 2

      at <anonymous> (/workspace/bun/test/js/web/url/url.test.ts:227:48)
(fail) url > hostname setter never writes the port: new URL("http://a/p"
... (truncated)

release without fix: 13 FAILED
bun test v1.4.0-canary.1 (1498d7b77)

test/js/web/url/url.test.ts:
(pass) url > URL throws [0.28ms]
(pass) url > should have correct origin and protocol [0.23ms]
(pass) url > blob urls [0.14ms]
(pass) url > prints [0.25ms]
(pass) url > works [0.25ms]
222 |     ["http://a/p", "[::1]", "http://[::1]/p"],
223 |     ["http://a:1/p", "[::1]", "http://[::1]:1/p"],
224 |   ])("hostname setter never writes the port: new URL(%j).hostname = %j", (base, value, expected) => {
225 |     const url = new URL(base);
226 |     url.hostname = value;
227 |     expect({ href: url.href, port: url.port }).toEqual({ href: expected, port: new URL(expected).port });
                                                     ^
error: expect(received).toEqual(expected)

  {
-   "href": "http://a/p",
-   "port": "",
+   "href": "http://[::1]:81/p",
+   "port": "81",
  }

- Expected  - 2
+ Received  + 2

      at <anonymous> (/workspace/bun/test/js/web/url/url.test.ts:227:48)
(fail) url > hostname setter never writes the port: new URL("http://a/p").hostname = "[::1]:81" [0.31ms]
222 |     ["http://a/p", "[::1]", "http://[::1]/p"],
223 |     ["http://a:1/p", "[::1]", "http://[::1]:1/p"],
224 |   ])("h
... (truncated)
passes on PR (with fix)
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/web/url/url.test.ts
bun test v1.4.0 (10f551cdf)

test/js/web/url/url.test.ts:
(pass) url > URL throws [12.76ms]
(pass) url > should have correct origin and protocol [13.11ms]
(pass) url > blob urls [9.01ms]
(pass) url > prints [7.86ms]
(pass) url > works [12.88ms]
(pass) url > hostname setter never writes the port: new URL("http://a/p").hostname = "[::1]:81" [2.64ms]
(pass) url > hostname setter never writes the port: new URL("https://a/p").hostname = "[2001:db8::7]:8443" [0.73ms]
(pass) url > hostname setter never writes the port: new URL("ws://u:pw@a/p").hostname = "[::1]:6666" [0.63ms]
(pass) url > hostname setter never writes the port: new URL("http://a/p").hostname = "[::ffff:127.0.0.1]:22" [0.58ms]
(pass) url > hostname setter never writes the port: new URL("http://a/p").hostname = "[::1]:" [0.80ms]
(pass) url > hostname setter never writes the port: new URL("http://a:1/p").hostname = "[::1]:81" [0.63ms]
(pass) url > hostname setter never writes the port: new URL("sc://a/p").hostname = "[::1]:81" [0.58ms]
(pass) url > hos
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 718ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/7] cxx obj/unified/UnifiedSource-src_jsc_bindings-4.cpp.o
[2/7] gen cpp.rs (cppbind)
[2/7] cargo bun_bin → libbun_rust.a (--target x86_64-unknown-linux-gnu)

  nightly-2026-07-20-x86_64-unknown-linux-gnu unchanged - rustc 1.99.0-nightly (9f36de775 2026-07-19)

�[1m�[92m   Compiling�[0m bun_core v0.0.0 (/workspace/bun/src/bun_core)
�[1m�[92m   Compiling�[0m bun_errno v0.0.0 (/workspace/bun/src/errno)
�[1m�[92m   Compiling�[0m bun_ptr v0.0.0 (/workspace/bun/src/ptr)
�[1m�[92m   Compiling�[0m bun_boringssl_sys v0.0.0 (/workspace/bun/src/boringssl_sys)
�[1m�[92m   Compiling�[0m bun_safety v0.0.0 (/workspace/bun/src/safety)
�[1m�[92m   Compiling�[0m bun_zlib_sys v0.0.0 (/workspace/bun/src/zlib_sys)
�[1m�[92m   Compiling�[0m bun_cares_sys v0.0.0 (/workspace/bun/src/cares_sys)
�[1m�[92m   Compiling�[0m bun_zstd v0.0.0 (/workspace/bun/src/zstd)
�[1m�[92m   Compiling�[0m bun_picohttp v0.0.0 (/workspace/bun/src/picohttp)
�[1m�[92m   Compiling�[0m bun_output v0.0.0 (/workspace/bun/src/output)
�[1m�[92m   Compili
... (truncated)
diff hotspot
src/jsc/bindings/URLDecomposition.cpp | 18 ++++++++++++++++
 test/js/web/url/url.test.ts           | 39 +++++++++++++++++++++++++++++++++++
 2 files changed, 57 insertions(+)

gate history · 1 passed · 1 rejected · iteration 2

evidence per changed file
file                                   reads  edits  tests
src/jsc/bindings/URLDecomposition.cpp      4      5      0
test/js/web/url/url.test.ts                3      3      0

The hostname setter is spec-defined as the host state with state override
= hostname state, which returns without setting anything when it meets a
":" outside "[...]". WTF::URL::setHost only rejects a ":" when the value
does not start with "[", so a bracketed IPv6 literal followed by ":port"
reached the reparse and filled the port slot:

    const u = new URL("http://a/p");
    u.hostname = "[::1]:81";
    u.href; // bun: "http://[::1]:81/p", spec/Node: "http://a/p"

Scan the value in URLDecomposition::setHostname, skipping ASCII
tab/newline and stopping at the host terminators WTF::URL::setHost
already truncates at, and return early on a ":" outside brackets.
@robobun

robobun commented Jul 27, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: closed by the inactivity sweep on 2026-10-26, not merged. Not reopened.

The bug still reproduces on main at 449877f. This PR is not reopened as is. Two paths reach the same guard in WTF::URL::setHost and do not pass through the check that this PR adds to URLDecomposition::setHostname:

  • URLPattern: new URLPattern({ hostname: "*" }).test({ hostname: "[::1]:81" }) is true. Node 26.3.0 gives false.
  • A fullwidth colon: hostname = "[::1]\uFF1A81" sets port 81. The check reads the raw value, and IDNA maps U+FF1A to : after it.

The fix belongs in WTF::URL::setHost in oven-sh/WebKit, where oven-sh/WebKit#724 already changes the same function. The shape of that change needs a maintainer decision first. I will link the follow-up here.

Reproduce: create new URL("http://a/p"), set hostname = "[::1]:81", read href. Bun gives http://[::1]:81/p. Node 26.3.0 gives http://a/p.

@coderabbitai

coderabbitai Bot commented Jul 27, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Changes

The hostname setter now scans host input for IPv6 bracket context and rejects out-of-bracket colons before calling setHost. Parameterized tests verify href and port behavior for bracketed IPv6 inputs.

Hostname setter behavior

Layer / File(s) Summary
Hostname guard and validation
src/jsc/bindings/URLDecomposition.cpp, test/js/web/url/url.test.ts
setHostname applies host-state scanning before updating the URL, while table-driven tests cover bracketed IPv6 inputs, delimiters, href results, and port values.

Suggested reviewers: jarred-sumner

🚥 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 summarizes the main fix: hostname setter no-ops when a colon appears outside IPv6 brackets.
Description check ✅ Passed The description covers what changed and how it was verified, even though its headings differ from the template.

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

@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: 2

🤖 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 `@test/js/web/url/url.test.ts`:
- Around line 204-211: Extend the URL test table around the existing HTAB and
backslash cases to cover CR, LF, and CRLF whitespace before the port delimiter,
plus backslash scanning when the URL uses a non-special scheme (special ==
false). Preserve the expected host/port parsing behavior and include explicit
CRLF input coverage.
- Around line 194-223: Replace the parameterized `it.each` suite for hostname
setter cases with `describe.each`, moving the shared test logic into a nested
regular `it` while preserving every case, title, and assertion. Verify the
updated test with the repository’s specified command.
🪄 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: be190c45-3706-470b-aa77-f832f2052182

📥 Commits

Reviewing files that changed from the base of the PR and between 4eb6f99 and 96402bd.

📒 Files selected for processing (2)
  • src/jsc/bindings/URLDecomposition.cpp
  • test/js/web/url/url.test.ts

Comment thread test/js/web/url/url.test.ts Resolved
Comment thread test/js/web/url/url.test.ts Resolved
@github-actions

Copy link
Copy Markdown
Contributor

No open issues found that this PR would fix.

🤖 Generated with Claude Code

Comment thread src/jsc/bindings/URLDecomposition.cpp Resolved
@robobun

robobun commented Jul 27, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 11:16 AM PT - Jul 27th, 2026

❌ @robobun, your commit 10f551c has 1 failures in Build #83474 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 36118

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

bun-36118 --bun

Comment thread src/jsc/bindings/URLDecomposition.cpp Resolved
@github-actions

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. url: follow the spec for the host/port setters, origin, and URLPattern groups #32826 - Broader spec-compliance PR that explicitly fixes the same hostname = "[::1]:80" colon-outside-brackets bug (example Add a way for frameworks to define env vars #5 in its description) and subsumes this change

🤖 Generated with Claude Code

Comment thread src/jsc/bindings/URLDecomposition.cpp Resolved

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

LGTM — the scan loop is back at the branch tip (blob 7a07a0f91d2, commit 5f143d5), so my earlier concern is resolved.

What was reviewed:

  • Cross-checked all 22 test expectations against Node — every one matches.
  • Confirmed the fix is scoped to setHostname and leaves setHost (which should parse ports) untouched.
  • Verified hasSpecialScheme() is already used elsewhere in this file, and the terminator set (/ ? # \\-on-special) plus tab/newline skipping matches the URL spec's host state.
Extended reasoning...

Overview

Two files: an 18-line pre-scan in URLDecomposition::setHostname (src/jsc/bindings/URLDecomposition.cpp) that returns early on a : outside [...] before forwarding to WTF URL::setHost, and a 22-row it.each table in test/js/web/url/url.test.ts covering the no-op cases, the terminator-before-colon cases, tab/CR/LF stripping, special vs non-special \\, and the still-accepted plain-bracket cases.

History on this PR

I previously flagged that commit 00d8d09 accidentally deleted the entire scan loop while trimming a comment, leaving the branch tip with tests but no implementation. Commit 5f143d5 restored it; git ls-tree HEAD now shows blob 7a07a0f91d2 for URLDecomposition.cpp, which is the intended post-fix content, and the net diff vs base is +18/+39 as expected.

Security risks

URL host parsing is SSRF-adjacent, but this change is strictly more restrictive: inputs like [::1]:81 that previously mutated both host and port now no-op, matching the WHATWG spec and Node/browsers. It cannot introduce a new host/port confusion — it removes one. I spot-checked seven representative rows against Node and all agree.

Level of scrutiny

Medium. It's a small, spec-cited change with a clear mechanism (the WTF setHost colon guard is bypassed by a leading [, so the caller pre-scans per the spec's host-state-with-override rule). The scan is placed at the correct layer — setHostname is the only setter with the "never touch port" invariant, and WTF URL::setHost is shared with the host setter which does parse ports, so patching there would be wrong.

Other factors

All review threads are resolved: comment-cop's length nag (comment reduced to a one-line spec reference matching the parsePort style in the same file), CodeRabbit's CR/LF and non-special-\\ coverage request (rows added in 3fcf5cd), and my dropped-implementation catch (restored in 5f143d5). The author declined the describe.each refactor, which is fine — it.each with a single assertion is idiomatic here. Fail-before/pass-after was verified (USE_SYSTEM_BUN=1: 11 fail; bun bd: 40 pass). The overlap with #32826 is noted in the PR body — this is the minimal targeted fix and #32826 would supersede it if it lands.

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

LGTM — the scan loop is back at HEAD (blob 7a07a0f91d2, restored in 5f143d5), so my earlier concern is resolved.

What was reviewed:

  • The setHostname bracket-tracking scan matches the WHATWG host-state rule (tab/newline skip, terminator break, :-outside-brackets return); the sibling setHost is intentionally left alone since the host setter is allowed to write the port.
  • Test table (22 cases) covers special/non-special schemes, existing-port preservation, tab/CR/LF stripping, all four terminators, and the positive path (plain [::1] still applies) — all cross-checked against Node.
  • The change strictly tightens behavior (no-op instead of port leak), so no new parse surface.
Extended reasoning...

Overview

Adds an 18-line pre-scan to URLDecomposition::setHostname (src/jsc/bindings/URLDecomposition.cpp) that returns early when the input contains a : outside an IPv6 [...] bracket pair, before delegating to WTF URL::setHost. This closes a hole in WTF's colon guard (contains(':') && !startsWith('[')), which let [::1]:81 reach the reparse and write the port. Adds a 22-row it.each table to test/js/web/url/url.test.ts with expected values verified against Node.

The branch went through some churn: 00d8d09 accidentally deleted the whole scan while trimming a comment (which I flagged in the previous review pass), and 5f143d5 restored it. I confirmed HEAD's blob for URLDecomposition.cpp is 7a07a0f91d2 — identical to what the PR diff shows — so the net change is now correct.

Security risks

URL parsing is security-adjacent (host/port confusion can enable SSRF or origin bypass), but this change moves behavior toward the spec by making the hostname setter refuse to touch the port when handed [v6]:port. It removes an unintended side-channel for port mutation rather than adding parse surface. The scan reads a bounded StringView character-by-character with no allocation, no arithmetic on untrusted lengths, and no memory-safety exposure.

Level of scrutiny

Medium-low. It is a targeted spec-compliance fix in a well-understood WebCore shim, with a single-line spec citation, a clear mechanical explanation of the WTF guard hole, and fail-before/pass-after verification (USE_SYSTEM_BUN=1 fails 11 of the new cases; bun bd passes 40/40). The loop mirrors the same shape already used in parsePort in the same file. The sibling URLDecomposition::setHost correctly does not get this guard because the host setter is spec-permitted to set the port.

Other factors

All review threads are resolved: the two comment-cop nags were addressed by trimming to a one-line spec reference, CodeRabbit's CR/LF and non-special-backslash coverage request landed in 3fcf5cd, and the it.each → describe.each suggestion was reasonably declined (matches file convention). PR #32826 is noted as a broader rewrite that would supersede this, but this minimal fix is independently landable.

@github-actions

Copy link
Copy Markdown
Contributor

Closing this PR because it has been inactive for more than 90 days.

@github-actions github-actions Bot closed this Oct 26, 2026
@robobun

robobun commented Oct 26, 2026

Copy link
Copy Markdown
Collaborator Author

The inactivity sweep closed this PR. No review decision closed it.

The bug is still present. On main at 449877f, URLDecomposition::setHostname still passes the value to WTF::URL::setHost with no check for a : outside the brackets. On canary 1.4.3 (367d939):

const u = new URL("http://a/p");
u.hostname = "[::1]:81";
u.href; // "http://[::1]:81/p". Node 26.3.0 gives "http://a/p".

I will link the follow-up here when the fix is ready again.

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.

2 participants