Skip to content

url: reject an empty string base in the URL constructor - #33210

Open
robobun wants to merge 4 commits into
mainfrom
farm/a98ffaea/url-empty-base
Open

url: reject an empty string base in the URL constructor#33210
robobun wants to merge 4 commits into
mainfrom
farm/a98ffaea/url-empty-base

Conversation

@robobun

@robobun robobun commented Jul 1, 2026

Copy link
Copy Markdown
Collaborator

Problem

new URL(absolute, "") succeeds when it should throw, and it disagrees with Bun's own URL.parse and URL.canParse on the same input:

new URL("http://x/", "").href    // Bun: "http://x/"   Node, Chrome, Safari: TypeError
URL.canParse("http://x/", "")    // false
URL.parse("http://x/", "")       // null

Per the URL spec, a provided base is always run through the URL parser, and an empty string is not a valid URL, so the constructor must throw even when the input is absolute. This breaks the canParse(x, base) && new URL(x, base) validation pattern: the guard says invalid and the constructor then accepts the construction anyway.

Cause

JSDOMURLDOMConstructor::construct dispatched on base.isEmpty() to decide whether a base was supplied:

auto object = base.isEmpty() ? DOMURL::create(url) : DOMURL::create(url, base);

A WTF null String (the base argument was undefined) and a real "" are both isEmpty(), so an empty string base was silently treated as no base. DOMURL::parse and DOMURL::canParse already use the correct isNull() predicate, which is why the statics disagreed with the constructor. The isEmpty() dispatch was introduced in #10129; before that the binding always called the two argument DOMURL::create(url, base), which checks base.isNull() itself.

Fix

Use isNull(), matching DOMURL::parse/canParse and the null String() sentinel the other in-tree DOMURL::create callers already pass for "no base".

new URL("http://x/", "")
// TypeError: "http://x/" cannot be parsed as a URL against ""  (code: ERR_INVALID_URL)

new URL(x) and new URL(x, undefined) keep the single argument path and its shorter error message, so the existing error message assertions are unchanged.

Verification

Tests added to test/js/web/url/url.test.ts:

  • rejects an empty string base covers the constructor, URL.parse, and URL.canParse agreeing on the same input. It fails on the unfixed build (the constructor returns a URL instead of throwing) and passes with the fix.
  • treats only an undefined base as no base guards the missing / undefined base paths that must keep working.
  • treats an explicit null base as an invalid base, not as no base documents the WebIDL coercion of a JS null to the string "null", a real (and invalid) base that all three entry points reject. This path was already correct and is unchanged by the fix; it pins down the remaining spelling of the base argument.

All existing tests in test/js/web/url/ and test/js/node/url/ pass. The only failures in those directories are two pre-existing 5s timeouts on loop tests that are slow under the debug + ASAN build (pathToFileURL doesn't leak memory, URL.canParse repeatedly called produces same result); they time out identically with and without this change and never reach the constructor's base dispatch.

The constructor binding dispatched on base.isEmpty() to decide whether a
base argument was supplied, but a WTF null String (base was undefined)
and a real empty string are both isEmpty(). An empty string base was
silently treated as no base, so new URL(absolute, "") succeeded while
URL.parse and URL.canParse (which use isNull()) rejected the same input.

Use isNull(), matching DOMURL::parse/canParse and the URL spec: a
provided base is always parsed, and an empty string is not a valid URL.
@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 8:38 PM PT - Jul 1st, 2026

@robobun, your commit ac0a42b has 5 failures in Build #67745 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 33210

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

bun-33210 --bun

@robobun

robobun commented Jul 1, 2026

Copy link
Copy Markdown
Collaborator Author

Status: the diff is green and ready for a maintainer. The CI red on this PR is test/bake/dev/production.test.ts, which is currently failing for every open PR branch and is unrelated to this change.

Reproduced on a stock 1.4.0 release build and on a debug build of current main:

new URL("http://x/", "").href    // "http://x/"  (should be a TypeError)
URL.canParse("http://x/", "")    // false
URL.parse("http://x/", "")       // null

Verified with the debug build: url > rejects an empty string base in test/js/web/url/url.test.ts fails with src/ stashed and passes with the fix applied.

About CI, with a correction to my earlier analysis here. In build 67745 the dominant failure is test/bake/dev/production.test.ts (production > works with sourcemaps - error thrown in React component, a 90s timeout). I first described it as a flaky test that moved between Windows lanes; with the build now finished it is clear it fails on 16 test lanes, every platform, so it is a deterministic breakage, not a flake. It is still not caused by this PR:

  • The identical test, same name, fails on 13 to 14 lanes in the same way on unrelated branches that do not contain this change, for example build 67740 (Bun.inspect circular JSX), build 67751 (spawn sigaction), and build 67744 (webstreams C++). A failure caused by a diff cannot appear on branches that do not contain the diff.
  • No failure annotation in this build, or the previous one, mentions url.test.ts or anything URL related. Bun's CI emits one annotation per failing test file, so this PR's tests passed on every lane.
  • The remaining annotated files (test/js/node/zlib/zlib.test.js on 5 lanes, test/js/node/test/parallel/test-net-connect-memleak.js on 2 Alpine lanes, test/js/bun/terminal/terminal.test.ts and test/regression/issue/20965.test.ts on 1 lane each) are ordinary flaky tests in unrelated subsystems; none of them touches URL.

I pushed one ci: retrigger already and will not push more: re-running cannot go green until the bake breakage is fixed at its source, for this PR or any other. Both automated reviews found no issues and there are no unresolved review threads.

@coderabbitai

coderabbitai Bot commented Jul 1, 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: 9900a0c1-89ac-435c-b7ba-9bc98c88d477

📥 Commits

Reviewing files that changed from the base of the PR and between dd33760 and ac0a42b.

📒 Files selected for processing (1)
  • test/js/web/url/url.test.ts

Walkthrough

This PR changes URL base handling so null is treated as no base, while empty string remains a real base. It also adds tests for empty-string, undefined, and null behavior across new URL, URL.canParse, and URL.parse.

Changes

URL base handling fix

Layer / File(s) Summary
Base null vs empty logic in DOMURL construct
src/jsc/bindings/webcore/JSDOMURL.cpp
Switched the base check from isEmpty() to isNull() before calling DOMURL::create, and updated the comment to reflect empty-string base handling.
Tests for empty-string, undefined, and null base
test/js/web/url/url.test.ts
Added tests asserting empty-string base is rejected, undefined base is treated as no base, and null base is coerced then rejected consistently across the URL APIs.

Compact metadata: URL constructor behavior and matching tests updated.
Related issues: None specified.
Related PRs: None specified.
Suggested labels: bug, url, tests, webcore
Suggested reviewers: None specified.

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly states the main behavior change: rejecting an empty-string base in the URL constructor.
Description check ✅ Passed The description covers the problem, cause, fix, and verification, which matches the template's intent.
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.

@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)
test/js/web/url/url.test.ts (1)

22-39: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Consider covering explicit null base, not just omitted/undefined.

The underlying fix distinguishes base.isNull() (only true when the JS argument is undefined/omitted) from a real base string. Passing a JS null literal explicitly goes through IDLUSVString conversion and coerces to the string "null" — a different code path than omission, and arguably the more interesting "null vs empty" case implied by the PR title. Consider adding a case for new URL("http://example.com/", null) (and the URL.canParse/URL.parse equivalents) to confirm it's treated as a real (and here, invalid) base rather than "no base".

🧪 Suggested additional test case
   it("treats only an undefined base as no base", () => {
     expect(new URL("http://example.com/", undefined).href).toBe("http://example.com/");
     expect(new URL("http://example.com/").href).toBe("http://example.com/");
     expect(URL.canParse("http://example.com/", undefined)).toBe(true);
     expect(URL.parse("http://example.com/", undefined)?.href).toBe("http://example.com/");
   });
+
+  it("treats an explicit null base as the string \"null\", not as no base", () => {
+    // `@ts-expect-error` - intentionally passing null to test WebIDL USVString coercion
+    expect(() => new URL("http://example.com/", null)).toThrow(expect.objectContaining({ code: "ERR_INVALID_URL" }));
+    // `@ts-expect-error`
+    expect(URL.canParse("http://example.com/", null)).toBe(false);
+    // `@ts-expect-error`
+    expect(URL.parse("http://example.com/", null)).toBeNull();
+  });

Based on the upstream constructor snippet in src/jsc/bindings/webcore/JSDOMURL.cpp (Lines 155-172), which special-cases argument1.value().isUndefined() distinctly from other falsy/null-ish values.

🤖 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/web/url/url.test.ts` around lines 22 - 39, The URL tests only cover
omitted/undefined base handling, but not an explicit null base, which follows a
different code path. Add a case in the URL constructor tests using new
URL("http://example.com/", null) and the matching URL.canParse/URL.parse
assertions to verify null is treated as a real invalid base rather than “no
base”. Keep the existing treats only an undefined base as no base coverage and
update the expectations to reflect the distinction made by JSDOMURL constructor
handling.
🤖 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 `@test/js/web/url/url.test.ts`:
- Around line 22-39: The URL tests only cover omitted/undefined base handling,
but not an explicit null base, which follows a different code path. Add a case
in the URL constructor tests using new URL("http://example.com/", null) and the
matching URL.canParse/URL.parse assertions to verify null is treated as a real
invalid base rather than “no base”. Keep the existing treats only an undefined
base as no base coverage and update the expectations to reflect the distinction
made by JSDOMURL constructor handling.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: d8e274c8-2eed-4934-a67b-ad2a2d38ea10

📥 Commits

Reviewing files that changed from the base of the PR and between f606a8b and dd33760.

📒 Files selected for processing (1)
  • test/js/web/url/url.test.ts

WebIDL coerces an explicit null base to the string "null", which is not
a valid URL, so the constructor, URL.canParse, and URL.parse all reject
it as an invalid base rather than treating it as no base.
@robobun

robobun commented Jul 1, 2026

Copy link
Copy Markdown
Collaborator Author

Added in 1492d92. An explicit null base is coerced to the string "null" by WebIDL, which is not a valid URL, so the constructor, URL.canParse, and URL.parse all reject it:

new URL("http://example.com/", null)
// TypeError: "http://example.com/" cannot be parsed as a URL against "null"  (ERR_INVALID_URL)
URL.canParse("http://example.com/", null)  // false
URL.parse("http://example.com/", null)     // null

This was already correct before the fix, since "null" is a non-null, non-empty string and takes the two argument DOMURL::create path either way. It is still worth pinning down as the remaining interesting spelling of the base argument alongside undefined (no base) and "" (an empty but real base), so I kept it.

One deviation from the suggested diff: I used a bare // @ts-expect-error, matching the existing usage in this file. TypeScript only recognizes the directive when it begins the comment, so the backtick wrapped form would not have been applied.

@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-token spec fix (isEmpty()isNull()) aligning the URL constructor with URL.parse/URL.canParse, with solid coverage.

Extended reasoning...

Overview

This PR changes a single predicate in JSDOMURLDOMConstructor::construct (src/jsc/bindings/webcore/JSDOMURL.cpp) from base.isEmpty() to base.isNull() when deciding whether to call the one- or two-argument DOMURL::create. It adds three test cases to test/js/web/url/url.test.ts covering "", undefined, and null as the base argument across new URL, URL.parse, and URL.canParse.

I verified against src/jsc/bindings/DOMURL.cpp: the two-argument DOMURL::create(url, base) and parseInternal (backing parse/canParse) both gate on !base.isNull() && !baseURL.isValid(), so an empty-but-non-null base string correctly hits the invalid-base error there. The old isEmpty() check in the constructor binding conflated a null WTF String() (produced when the JS argument is undefined) with a real "", silently dropping the empty base. Switching to isNull() restores parity with the statics and matches the WHATWG URL spec, Node, Chrome, and Safari.

Security risks

None. This tightens validation (an input that previously succeeded now throws ERR_INVALID_URL), which is strictly safer. No auth, crypto, or filesystem paths are touched.

Level of scrutiny

Low. This is effectively a one-word change in a WebCore binding whose correctness is directly verifiable against both the sibling code paths in DOMURL.cpp and the URL Standard. The behavioral change is narrow (only new URL(x, "") is affected) and moves from non-conformant to spec-conformant.

Other factors

  • No CODEOWNERS coverage for the touched files.
  • Tests assert exact error messages and ERR_INVALID_URL code, plus cross-check the constructor against canParse/parse on the same inputs, and pin down the preserved undefined and already-correct null behavior — good regression coverage for the whole variant matrix.
  • The bug hunter found no issues.
  • No prior human reviews or outstanding comments to address.

@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-token predicate swap (isEmpty()isNull()) that aligns the constructor with DOMURL::parse/canParse and the spec, with tests covering the fix and both adjacent guards.

Extended reasoning...

Overview

A single-line change in src/jsc/bindings/webcore/JSDOMURL.cpp: the base-argument dispatch in JSDOMURLDOMConstructor::construct switches from base.isEmpty() to base.isNull(). The rest is three new test cases in test/js/web/url/url.test.ts and a two-line explanatory comment.

I verified against src/jsc/bindings/DOMURL.cpp: the two-arg DOMURL::create(url, base) overload already guards on !base.isNull() && !baseURL.isValid(), and parseInternal (backing URL.parse/URL.canParse) uses the identical isNull() predicate. So the constructor was the only outlier, and this change makes all three entry points agree — exactly as the URL spec requires (a provided base is always parsed, and "" is not a valid URL).

Security risks

None. This tightens validation (previously-accepted new URL(abs, "") now throws), which is the spec-mandated and Node/Chrome/Safari behavior. No new parsing, no memory management, no allocation — just a predicate swap on a local WTF::String.

Level of scrutiny

Low. The functional change is one identifier on one line in an originally-generated binding file, bringing it into line with its siblings. The undefined → null-String() sentinel path two lines above is unchanged, so the no-base case is preserved (and now explicitly tested). Blast radius is limited to the exact edge case being fixed.

Other factors

Test coverage is thorough for the size of the change: the failing case (empty-string base), the must-still-work case (undefined/omitted base), and the adjacent null"null" WebIDL coercion are all pinned across the constructor, URL.parse, and URL.canParse. The PR description confirms the new test fails on the unfixed build. The bug-hunting system found no issues, and there are no outstanding human review comments.

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