Skip to content

url: give blob: URLs a null origin when the inner host has invalid punycode - #39404

Open
robobun wants to merge 1 commit into
mainfrom
farm/7287b84f/blob-origin-invalid-punycode
Open

url: give blob: URLs a null origin when the inner host has invalid punycode#39404
robobun wants to merge 1 commit into
mainfrom
farm/7287b84f/blob-origin-invalid-punycode

Conversation

@robobun

@robobun robobun commented Aug 17, 2026

Copy link
Copy Markdown
Collaborator

Problem

Fix

  • The blob: branch of origin() now also requires hasAcceptableHost(subUrl), the helper the host/hostname setters in the same file already use. An inner URL with an invalid punycode label is treated as unparsed, so the origin is "null".
  • Correct because it applies the same rule the constructor applies to the same input: a tuple origin is only produced for an inner URL that new URL(inner) would accept. Hosts without xn-- return from the check without touching ICU; parser-produced punycode (from a Unicode inner host) still validates, which the test covers.
  • Test: test/js/web/url/url.test.ts, "blob: origin is null when the inner URL has an invalid punycode label (like Node)". Fails on the released 1.4 build (Received: "http://xn--a.com"), passes with this change.
  • bun bd test test/js/web/url (1303 pass, includes the WPT url-constructor origin expectations) and test/js/node/url pass; the one failure there is url-canParse-whatwg.test.js timing out a 1e5-iteration loop under the debug ASAN build on a loaded box, which does not go through origin().
  • The 15 inner URLs in the details block below now print the same as Node 26.3 byte for byte.

Background

  • Punycode / xn-- label: the ASCII encoding of a Unicode domain label (xn--ls8h is the pile of poo emoji). UTS Bun v0.0.41 #46 (IDNA) defines which xn-- labels are valid; xn--a does not decode to anything, so ada (Node's URL parser) fails to parse hosts containing it.
  • hasValidPunycodeHost (src/jsc/bindings/NodeURL.cpp): Bun's port of that check, run on top of WebKit's parser, which only lowercases all-ASCII hosts. hasAcceptableHost in URLDecomposition.cpp wraps it and skips non-special schemes, whose hosts are opaque and never go through IDNA.
  • blob: origin (https://url.spec.whatwg.org/#concept-url-origin): a blob: URL's origin is the origin of the URL obtained by parsing its path; if that parse fails the origin is opaque, serialized as "null".
Before / after for the inner URLs checked
inner URL                       new URL(inner)   blob origin before     blob origin after (= node 26.3)
http://xn--a.com/               throws           http://xn--a.com       null
https://XN--A.com/x             throws           https://xn--a.com      null
http://xn--a/                   throws           http://xn--a           null
http://x%6E--a.com/             throws           http://xn--a.com       null
ftp://xn--nxasmq6b.xn--a.com/   throws           ftp://xn--nxasmq6b...  null
ws://xn--a-.com/                throws           ws://xn--a-.com        null
wss://xn---.com/                throws           wss://xn---.com        null
file://xn--a/x                  throws           file://xn--a           null
http://xn--ls8h.la/             ok               http://xn--ls8h.la     http://xn--ls8h.la
https://xn--ls8h.la:8443/x      ok               https://xn--ls8h.la:8443  https://xn--ls8h.la:8443
http://münchen.de/x             ok               http://xn--mnchen-3ya.de  http://xn--mnchen-3ya.de
http://xn--ls8h.la/xn--a        ok               http://xn--ls8h.la     http://xn--ls8h.la
http://ok.example/?xn--a        ok               http://ok.example      http://ok.example
foo://xn--a/                    ok               null                   null
http://example.com:8080/x       ok               http://example.com:8080  http://example.com:8080

Where the rejection went: the WTF URLParser at the WebKit pin used by Bun 1.3.14 still had subdomainStartsWithXNDashDash, which sent ASCII xn-- hosts through ICU; the pin from #32414 onward does not. Bun's own check (#34660) covers DOMURL::create, parse, canParse, setHref, setHost and setHostname; origin() was the remaining path that parses user input into a host.


[stamp-90s] gate passed · iteration 0 · 2 files touched

fails on main (without fix)
ASAN without fix: 1 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 (8326d1bd3)

test/js/web/url/url.test.ts:
(pass) url > URL throws [13.03ms]
(pass) url > ERR_INVALID_URL carries input and, when given, base [9.07ms]
(pass) url > should have correct origin and protocol [17.54ms]
(pass) url > blob urls [11.52ms]
(pass) url > leaves opaque (non-special-scheme) hosts unchanged [12.33ms]
(pass) url > special-scheme hosts use the Unicode 16 IDNA table [7.57ms]
(pass) url > rejects invalid punycode labels however they are spelled in the input (like Node) [33.67ms]
188 |       "wss://xn---.com/",
189 |       "ftp://xn--a.com/",
190 |       "file://xn--a/x",
191 |     ]) {
192 |       expect(() => new URL(inner)).toThrow(TypeError);
193 |       expect(new URL("blob:" + inner).origin).toBe("null");
                                                    ^
error: expect(received).toBe(expected)

Expected: "null"
Received: "http://xn--a.com"

      at <anonymous> (/workspace/bun/test/js/web/url/url.test.ts:193:47)
(fail) url > blob: origin is null when the inner URL has an
... (truncated)

release without fix: 1 FAILED
bun test v1.4.0-canary.1 (8326d1bd3)

test/js/web/url/url.test.ts:
(pass) url > URL throws [0.14ms]
(pass) url > ERR_INVALID_URL carries input and, when given, base [0.12ms]
(pass) url > should have correct origin and protocol [0.11ms]
(pass) url > blob urls [0.06ms]
(pass) url > leaves opaque (non-special-scheme) hosts unchanged [0.10ms]
(pass) url > special-scheme hosts use the Unicode 16 IDNA table [0.09ms]
(pass) url > rejects invalid punycode labels however they are spelled in the input (like Node) [0.22ms]
188 |       "wss://xn---.com/",
189 |       "ftp://xn--a.com/",
190 |       "file://xn--a/x",
191 |     ]) {
192 |       expect(() => new URL(inner)).toThrow(TypeError);
193 |       expect(new URL("blob:" + inner).origin).toBe("null");
                                                    ^
error: expect(received).toBe(expected)

Expected: "null"
Received: "http://xn--a.com"

      at <anonymous> (/workspace/bun/test/js/web/url/url.test.ts:193:47)
(fail) url > blob: origin is null when the inner URL has an invalid punycode label (like Node) [0.24ms]
(pass) url > prints [1.83ms]
(pass) url > URLContext offsets account for the /. pathname guard [0.71ms]
(pass) u
... (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 (8326d1bd3)

test/js/web/url/url.test.ts:
(pass) url > URL throws [17.64ms]
(pass) url > ERR_INVALID_URL carries input and, when given, base [11.30ms]
(pass) url > should have correct origin and protocol [12.55ms]
(pass) url > blob urls [7.39ms]
(pass) url > leaves opaque (non-special-scheme) hosts unchanged [7.68ms]
(pass) url > special-scheme hosts use the Unicode 16 IDNA table [4.61ms]
(pass) url > rejects invalid punycode labels however they are spelled in the input (like Node) [25.54ms]
(pass) url > blob: origin is null when the inner URL has an invalid punycode label (like Node) [22.09ms]
(pass) url > prints [102.38ms]
(pass) url > URLContext offsets account for the /. pathname guard [67.32ms]
(pass) url > works [21.96ms]
(pass) url > URL.canParse > URL.canParse(undefined, undefined) [3.08ms]
(pass) url > URL.canParse > URL.canParse(a:b, undefined) [0.53ms]
(pass) url > URL.canParse > URL.canParse(undefined, a:b) [0.42ms]
(pass) url > URL.canParse > URL.canParse(a:/b, undefined) [0.38ms
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 830ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/1236] install /workspace/bun
bun install v1.4.0-canary.1 (8326d1bd3)

Checked 107 installs across 153 packages (no changes) [7.00ms]
[2/1236] install /workspace/bun/packages/bun-error
bun install v1.4.0-canary.1 (8326d1bd3)

Checked 1 install across 2 packages (no changes) [2.00ms]
[3/1236] gen ErrorCode+*.h
[4/1236] install /workspace/bun/src/node-fallbacks
bun install v1.4.0-canary.1 (8326d1bd3)

Checked 129 installs across 147 packages (no changes) [12.00ms]
[5/1236] gen bindgenv2
[6/1236] fetch zlib
[zlib] up to date
[7/1236] gen .bind.ts → GeneratedBindings.cpp
[8/1236] fetch libjpeg-turbo
[libjpeg-turbo] up to date
[9/1236] fetch tinycc
[tinycc] up to date
[10/1235] subst deps/zlib/zlib.h
[11/1235] subst deps/zlib/zconf.h
[12/1186] gen ProcessBindingConstants.lut.h
Generating /workspace/bun/build/release/codegen/ProcessBindingConstants.lut.h from /workspace/bun/src/jsc/bindings/ProcessBindingConstants.cpp
[13/1186] gen JSBuffer.lut.h
Generating /workspace/bun/build/release/codegen/JSBuffer.lut.h
... (truncated)
diff hotspot
src/jsc/bindings/URLDecomposition.cpp |  6 +++---
 test/js/web/url/url.test.ts           | 34 ++++++++++++++++++++++++++++++++++
 2 files changed, 37 insertions(+), 3 deletions(-)

gate history · 1 passed · 0 rejected · iteration 0

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

…nycode

URLDecomposition::origin() re-parses a blob: URL's path with WTF::URL
alone. Since the WebKit bump that removed the parser's own xn-- check,
that parse accepts hosts such as xn--a.com that the URL constructor,
URL.parse, URL.canParse and the host setters reject through
hasValidPunycodeHost, so new URL("blob:http://xn--a.com/").origin
returned "http://xn--a.com" while new URL("http://xn--a.com/") threw.

Apply the same host check to the inner URL, so it counts as unparsed and
the origin is "null", matching Node and Bun 1.3.
@robobun

robobun commented Aug 17, 2026

Copy link
Copy Markdown
Collaborator Author

Status: ready for review.

Reproduced on the released 1.4 build with new URL("blob:http://xn--a.com/").origin (returns http://xn--a.com; new URL("http://xn--a.com/") throws on the same build, Node and 1.3.14 return null). The new test in test/js/web/url/url.test.ts fails on that build and passes with this branch; test/js/web/url and test/js/node/url pass with the debug build.

@robobun

robobun commented Aug 17, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 9:23 PM PT - Aug 16th, 2026

@robobun, your commit 8a8da159f557d258569adfc3952153e12b92f7be passed in Build #99836! 🎉


🧪   To try this PR locally:

bunx bun-pr 39404

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

bun-39404 --bun

@coderabbitai

coderabbitai Bot commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 749c92c3-bc71-4f59-b9a4-e06694f94160

📥 Commits

Reviewing files that changed from the base of the PR and between 1367067 and 8a8da15.

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

Included review availability: Your plan includes up to 5 reviews per rolling hour; 0 remain after this review.


Walkthrough

Blob URL origin extraction now rejects inner URLs with unacceptable special-scheme hosts, including invalid xn-- labels. Tests cover invalid punycode across supported schemes and valid origin serialization.

Changes

Blob URL validation

Layer / File(s) Summary
Blob origin validation and coverage
src/jsc/bindings/URLDecomposition.cpp, test/js/web/url/url.test.ts
Blob origins now require a valid inner URL with an acceptable host. Tests cover invalid punycode rejection and valid origins with IDNA conversion and path/query exclusion.

Possibly related PRs

  • oven-sh/bun#38013: Modifies URL host and origin handling for invalid xn-- labels through a different code path.
  • oven-sh/bun#39273: Addresses URL host and punycode validation in related origin and parsing behavior.

Suggested reviewers: jarred-sumner, cirospaciari

🚥 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 blob URL origins with invalid punycode hosts.
Description check ✅ Passed The description explains the problem, fix, expected behavior, affected code, and verification results in sufficient detail.

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

@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 — one-line fix reusing the existing hasAcceptableHost helper for the last path #34660 missed.

What was reviewed:

  • Confirmed origin() is the only remaining spot in URLDecomposition.cpp that constructs a WTF::URL from user input; setHost/setHostname already have this exact check.
  • Checked that the inner scheme filter (http/https/ftp/ws/wss/file) means only special schemes reach protocolHostAndPort(), so the !hasSpecialScheme() short-circuit in the helper is inert here — the change is purely a tightening.
  • Test covers every scheme in that filter, plus positive cases (valid xn--ls8h, Unicode → punycode, port, xn--a in path/query) proving no regression.
Extended reasoning...

Overview

Adds && hasAcceptableHost(subUrl) to the blob: branch of URLDecomposition::origin() so that a blob: URL whose inner path re-parses to a host with an invalid xn-- label yields origin "null" instead of a tuple origin. Also updates the helper's comment and adds a test in test/js/web/url/url.test.ts.

Security risks

Origin is security-relevant, but this change is strictly a tightening: inputs that previously produced a tuple origin now produce "null", matching Node, Bun 1.3.14, and the spec (an inner URL that fails to parse yields an opaque origin). No new surface is exposed and no origin becomes more permissive.

Level of scrutiny

Low-to-medium. The functional diff is a single boolean conjunct calling a static helper already used identically two places in the same file (setHost, setHostname). The PR description traces exactly why this path was missed (#34660 covered constructor/parse/canParse/href/host/hostname setters; blob-origin re-parse was the remaining site). The helper's || !url.hasSpecialScheme() fallback is moot here because the very next line only accepts special-scheme inner URLs anyway.

Other factors

  • Test asserts both directions: nine invalid-punycode inner URLs (across http/https/ws/wss/ftp/file, including uppercase XN--, percent-encoded x%6E--, multi-label, and degenerate xn---/xn--a-) each throw from new URL(inner) and give "null" from new URL("blob:"+inner).origin; five valid inner URLs (valid punycode, Unicode host, port, xn--a in path/query) still produce the correct tuple origin.
  • PR states the test fails on the released 1.4 build and passes with this change; test/js/web/url (including WPT origin expectations) and test/js/node/url pass.
  • No memory, threading, or exception-scope concerns — pure const string logic on a stack-local WTF::URL.

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