Compare commits

...

2 Commits

Author SHA1 Message Date
Claude Bot
e98a9c074b refactor: simplify host check in formatWhatwgUrl
WHATWG URL host is always a string, never null.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-19 22:09:23 +00:00
Claude Bot
6761760459 fix(node:url): preserve credentials in url.format() for WHATWG URLs
`url.format()` was stripping username and password credentials from WHATWG
`URL` objects because it was using `Url.prototype.format` which looks for
`this.auth`, but WHATWG URLs store credentials in `username`/`password`.

This fix adds a dedicated `formatWhatwgUrl()` function that properly handles
WHATWG URL objects and supports all format options: `auth`, `fragment`,
`search`, and `unicode`.

Fixes #24343

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-19 21:58:18 +00:00
3 changed files with 130 additions and 54 deletions

View File

@@ -461,8 +461,14 @@ function getHostname(self, rest, hostname: string, url) {
}
// format a parsed object into a url string
declare function urlFormat(urlObject: string | URL | Url): string;
function urlFormat(urlObject: unknown) {
interface UrlFormatOptions {
auth?: boolean;
fragment?: boolean;
search?: boolean;
unicode?: boolean;
}
declare function urlFormat(urlObject: string | URL | Url, options?: UrlFormatOptions): string;
function urlFormat(urlObject: unknown, options?: UrlFormatOptions) {
/*
* ensure it's an object, and not a string url.
* If it's an obj, this is a no-op.
@@ -476,12 +482,79 @@ function urlFormat(urlObject: unknown) {
throw $ERR_INVALID_ARG_TYPE("urlObject", ["Object", "string"], urlObject);
}
// Handle WHATWG URL objects
if (urlObject instanceof URL) {
if (options !== undefined && typeof options !== "object") {
throw $ERR_INVALID_ARG_TYPE("options", "object", options);
}
return formatWhatwgUrl(urlObject, options);
}
if (!(urlObject instanceof Url)) {
return Url.prototype.format.$call(urlObject);
}
return urlObject.format();
}
function formatWhatwgUrl(urlObject: URL, options?: UrlFormatOptions): string {
// Default all options to true
let auth = true;
let fragment = true;
let search = true;
let unicode = false;
if (options) {
if (options.auth !== undefined) {
auth = Boolean(options.auth);
}
if (options.fragment !== undefined) {
fragment = Boolean(options.fragment);
}
if (options.search !== undefined) {
search = Boolean(options.search);
}
if (options.unicode !== undefined) {
unicode = Boolean(options.unicode);
}
}
let result = urlObject.protocol;
if (urlObject.host) {
result += "//";
if (auth && (urlObject.username || urlObject.password)) {
result += urlObject.username;
if (urlObject.password) {
result += ":" + urlObject.password;
}
result += "@";
}
if (unicode) {
result += domainToUnicode(urlObject.hostname);
} else {
result += urlObject.hostname;
}
if (urlObject.port) {
result += ":" + urlObject.port;
}
}
result += urlObject.pathname;
if (search && urlObject.search) {
result += urlObject.search;
}
if (fragment && urlObject.hash) {
result += urlObject.hash;
}
return result;
}
Url.prototype.format = function format() {
var auth: string = this.auth || "";
if (auth) {

View File

@@ -1,78 +1,72 @@
import { describe, test } from "bun:test";
import { describe, expect, test } from "bun:test";
import assert from "node:assert";
import url, { URL } from "node:url";
describe("url.format", () => {
test("WHATWG", () => {
test("WHATWG URL with credentials", () => {
const myURL = new URL("http://user:pass@xn--lck1c3crb1723bpq4a.com/a?a=b#c");
// TODO: Support these.
//
// assert.strictEqual(url.format(myURL), "http://user:pass@xn--lck1c3crb1723bpq4a.com/a?a=b#c");
// Default should include credentials
assert.strictEqual(url.format(myURL), "http://user:pass@xn--lck1c3crb1723bpq4a.com/a?a=b#c");
assert.strictEqual(url.format(myURL, {}), "http://user:pass@xn--lck1c3crb1723bpq4a.com/a?a=b#c");
// assert.strictEqual(url.format(myURL, {}), "http://user:pass@xn--lck1c3crb1723bpq4a.com/a?a=b#c");
// TODO: Support this kind of assert.throws.
// {
// [true, 1, "test", Infinity].forEach(value => {
// assert.throws(() => url.format(myURL, value), {
// code: "ERR_INVALID_ARG_TYPE",
// name: "TypeError",
// message: 'The "options" argument must be of type object.',
// });
// });
// }
// Invalid options should throw
[true, 1, "test", Infinity].forEach(value => {
expect(() => url.format(myURL, value)).toThrow(TypeError);
});
// Any falsy value other than undefined will be treated as false.
// Any truthy value will be treated as true.
// auth: false should strip credentials
assert.strictEqual(url.format(myURL, { auth: false }), "http://xn--lck1c3crb1723bpq4a.com/a?a=b#c");
assert.strictEqual(url.format(myURL, { auth: "" }), "http://xn--lck1c3crb1723bpq4a.com/a?a=b#c");
assert.strictEqual(url.format(myURL, { auth: 0 }), "http://xn--lck1c3crb1723bpq4a.com/a?a=b#c");
// TODO: Support these.
//
// assert.strictEqual(url.format(myURL, { auth: 1 }), "http://user:pass@xn--lck1c3crb1723bpq4a.com/a?a=b#c");
// auth: truthy should include credentials
assert.strictEqual(url.format(myURL, { auth: 1 }), "http://user:pass@xn--lck1c3crb1723bpq4a.com/a?a=b#c");
assert.strictEqual(url.format(myURL, { auth: {} }), "http://user:pass@xn--lck1c3crb1723bpq4a.com/a?a=b#c");
// assert.strictEqual(url.format(myURL, { auth: {} }), "http://user:pass@xn--lck1c3crb1723bpq4a.com/a?a=b#c");
// fragment: false should strip hash
assert.strictEqual(url.format(myURL, { fragment: false }), "http://user:pass@xn--lck1c3crb1723bpq4a.com/a?a=b");
assert.strictEqual(url.format(myURL, { fragment: "" }), "http://user:pass@xn--lck1c3crb1723bpq4a.com/a?a=b");
assert.strictEqual(url.format(myURL, { fragment: 0 }), "http://user:pass@xn--lck1c3crb1723bpq4a.com/a?a=b");
// assert.strictEqual(url.format(myURL, { fragment: false }), "http://user:pass@xn--lck1c3crb1723bpq4a.com/a?a=b");
// fragment: truthy should include hash
assert.strictEqual(url.format(myURL, { fragment: 1 }), "http://user:pass@xn--lck1c3crb1723bpq4a.com/a?a=b#c");
assert.strictEqual(url.format(myURL, { fragment: {} }), "http://user:pass@xn--lck1c3crb1723bpq4a.com/a?a=b#c");
// assert.strictEqual(url.format(myURL, { fragment: "" }), "http://user:pass@xn--lck1c3crb1723bpq4a.com/a?a=b");
// search: false should strip search
assert.strictEqual(url.format(myURL, { search: false }), "http://user:pass@xn--lck1c3crb1723bpq4a.com/a#c");
assert.strictEqual(url.format(myURL, { search: "" }), "http://user:pass@xn--lck1c3crb1723bpq4a.com/a#c");
assert.strictEqual(url.format(myURL, { search: 0 }), "http://user:pass@xn--lck1c3crb1723bpq4a.com/a#c");
// assert.strictEqual(url.format(myURL, { fragment: 0 }), "http://user:pass@xn--lck1c3crb1723bpq4a.com/a?a=b");
// search: truthy should include search
assert.strictEqual(url.format(myURL, { search: 1 }), "http://user:pass@xn--lck1c3crb1723bpq4a.com/a?a=b#c");
assert.strictEqual(url.format(myURL, { search: {} }), "http://user:pass@xn--lck1c3crb1723bpq4a.com/a?a=b#c");
// assert.strictEqual(url.format(myURL, { fragment: 1 }), "http://user:pass@xn--lck1c3crb1723bpq4a.com/a?a=b#c");
// unicode: true should convert punycode to unicode
assert.strictEqual(url.format(myURL, { unicode: true }), "http://user:pass@理容ナカムラ.com/a?a=b#c");
assert.strictEqual(url.format(myURL, { unicode: 1 }), "http://user:pass@理容ナカムラ.com/a?a=b#c");
assert.strictEqual(url.format(myURL, { unicode: {} }), "http://user:pass@理容ナカムラ.com/a?a=b#c");
// assert.strictEqual(url.format(myURL, { fragment: {} }), "http://user:pass@xn--lck1c3crb1723bpq4a.com/a?a=b#c");
// unicode: false/default should keep punycode
assert.strictEqual(url.format(myURL, { unicode: false }), "http://user:pass@xn--lck1c3crb1723bpq4a.com/a?a=b#c");
assert.strictEqual(url.format(myURL, { unicode: 0 }), "http://user:pass@xn--lck1c3crb1723bpq4a.com/a?a=b#c");
// assert.strictEqual(url.format(myURL, { search: false }), "http://user:pass@xn--lck1c3crb1723bpq4a.com/a#c");
// assert.strictEqual(url.format(myURL, { search: "" }), "http://user:pass@xn--lck1c3crb1723bpq4a.com/a#c");
// assert.strictEqual(url.format(myURL, { search: 0 }), "http://user:pass@xn--lck1c3crb1723bpq4a.com/a#c");
// assert.strictEqual(url.format(myURL, { search: 1 }), "http://user:pass@xn--lck1c3crb1723bpq4a.com/a?a=b#c");
// assert.strictEqual(url.format(myURL, { search: {} }), "http://user:pass@xn--lck1c3crb1723bpq4a.com/a?a=b#c");
// assert.strictEqual(url.format(myURL, { unicode: true }), "http://user:pass@理容ナカムラ.com/a?a=b#c");
// assert.strictEqual(url.format(myURL, { unicode: 1 }), "http://user:pass@理容ナカムラ.com/a?a=b#c");
// assert.strictEqual(url.format(myURL, { unicode: {} }), "http://user:pass@理容ナカムラ.com/a?a=b#c");
// assert.strictEqual(url.format(myURL, { unicode: false }), "http://user:pass@xn--lck1c3crb1723bpq4a.com/a?a=b#c");
// assert.strictEqual(url.format(myURL, { unicode: 0 }), "http://user:pass@xn--lck1c3crb1723bpq4a.com/a?a=b#c");
// assert.strictEqual(
// url.format(new URL("http://user:pass@xn--0zwm56d.com:8080/path"), { unicode: true }),
// "http://user:pass@测试.com:8080/path",
// );
// Test with port
assert.strictEqual(
url.format(new URL("http://user:pass@xn--0zwm56d.com:8080/path"), { unicode: true }),
"http://user:pass@测试.com:8080/path",
);
// tel: URLs should be equal with or without unicode option
assert.strictEqual(url.format(new URL("tel:123")), url.format(new URL("tel:123"), { unicode: true }));
});
test("regression test for issue #24343 - credentials stripped from URL", () => {
// The original bug report
const myURL = new URL("https://a:b@example.org/");
assert.strictEqual(url.format(myURL), "https://a:b@example.org/");
});
});

View File

@@ -0,0 +1,9 @@
import { expect, test } from "bun:test";
import url from "node:url";
// https://github.com/oven-sh/bun/issues/24343
// url.format() strips username and password from WHATWG URL objects
test("url.format() preserves credentials from WHATWG URL objects", () => {
const myURL = new URL("https://a:b@example.org/");
expect(url.format(myURL)).toBe("https://a:b@example.org/");
});