From 8841ebc9f9cd75dd5f7d2c70a4f85b40157b03a0 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 31 Jul 2026 09:38:59 +0000 Subject: [PATCH 1/3] publish: poll cross-origin web login doneUrl without credentials When a registry's OTP web login response returns a doneUrl on a different origin than the registry itself, bun publish was silently abandoning the web flow and falling back to the classic "Enter OTP:" prompt with no explanation. The guard existed because the doneUrl poll reused construct_publish_headers, which attaches the registry's Authorization header and a registry-derived Host header. Sending those to an arbitrary origin would leak the token, so the flow was aborted instead. Match npm's behavior (npm-registry-fetch scopes credentials by registry): keep running the web flow and poll the cross-origin doneUrl, but only attach the Authorization header (and the registry-derived Host override) when doneUrl is same-origin with the registry. Same-origin polling is unchanged and keeps the Bearer/Basic header. --- src/runtime/cli/publish_command.rs | 67 +++++++++++++++++----------- test/cli/install/bun-publish.test.ts | 34 +++++++++----- 2 files changed, 62 insertions(+), 39 deletions(-) diff --git a/src/runtime/cli/publish_command.rs b/src/runtime/cli/publish_command.rs index 0d57238f4292..079bdba88ac2 100644 --- a/src/runtime/cli/publish_command.rs +++ b/src/runtime/cli/publish_command.rs @@ -943,6 +943,7 @@ impl PublishCommand { }, ctx.uses_workspaces, ctx.manager.options.publish_config.auth_type, + true, )?; let mut response_buf = MutableString::init(1024)?; @@ -1050,6 +1051,7 @@ impl PublishCommand { Some(&otp), ctx.uses_workspaces, ctx.manager.options.publish_config.auth_type, + true, )?; response_buf.reset(); @@ -1184,16 +1186,17 @@ impl PublishCommand { break 'try_web; }; let done_url = URL::parse(crate::cli::cli_dupe(done_url_str)); - { - let registry_url = registry.url.url(); - if !(done_url.is_http() || done_url.is_https()) - || done_url.protocol != registry_url.protocol - || done_url.hostname != registry_url.hostname - || done_url.get_port_auto() != registry_url.get_port_auto() - { - break 'try_web; - } + if !(done_url.is_http() || done_url.is_https()) { + break 'try_web; } + // npm-registry-fetch scopes credentials by registry: attach auth + // only when polling the registry's own origin. + let done_url_same_origin = { + let registry_url = registry.url.url(); + done_url.is_https() == registry_url.is_https() + && done_url.hostname == registry_url.hostname + && done_url.get_port_auto() == registry_url.get_port_auto() + }; if auth_url_is_web { bun_core::prettyln!( @@ -1285,6 +1288,7 @@ impl PublishCommand { None, ctx.uses_workspaces, ctx.manager.options.publish_config.auth_type, + done_url_same_origin, )?; loop { @@ -1915,6 +1919,7 @@ impl PublishCommand { maybe_otp: Option<&[u8]>, uses_workspaces: bool, auth_type: Option, + include_auth: bool, ) -> Result { let mut headers = http::HeaderBuilder::default(); let npm_auth_type: &[u8] = if maybe_otp.is_none() { @@ -1932,14 +1937,16 @@ impl PublishCommand { headers.count(b"accept", b"*/*"); headers.count(b"accept-encoding", b"gzip,deflate"); - if !registry.token.is_empty() { - write!(print_buf, "Bearer {}", bstr::BStr::new(®istry.token)).ok(); - headers.count(b"authorization", &**print_buf); - print_buf.clear(); - } else if !registry.auth.is_empty() { - write!(print_buf, "Basic {}", bstr::BStr::new(®istry.auth)).ok(); - headers.count(b"authorization", &**print_buf); - print_buf.clear(); + if include_auth { + if !registry.token.is_empty() { + write!(print_buf, "Bearer {}", bstr::BStr::new(®istry.token)).ok(); + headers.count(b"authorization", &**print_buf); + print_buf.clear(); + } else if !registry.auth.is_empty() { + write!(print_buf, "Basic {}", bstr::BStr::new(®istry.auth)).ok(); + headers.count(b"authorization", &**print_buf); + print_buf.clear(); + } } if maybe_json_len.is_some() { @@ -1969,7 +1976,9 @@ impl PublishCommand { print_buf.clear(); headers.count(b"Connection", b"keep-alive"); - headers.count(b"Host", registry.url.url().host); + if include_auth { + headers.count(b"Host", registry.url.url().host); + } if let Some(json_len) = maybe_json_len { write!(print_buf, "{}", json_len).ok(); @@ -1984,14 +1993,16 @@ impl PublishCommand { headers.append(b"accept", b"*/*"); headers.append(b"accept-encoding", b"gzip,deflate"); - if !registry.token.is_empty() { - write!(print_buf, "Bearer {}", bstr::BStr::new(®istry.token)).ok(); - headers.append(b"authorization", &**print_buf); - print_buf.clear(); - } else if !registry.auth.is_empty() { - write!(print_buf, "Basic {}", bstr::BStr::new(®istry.auth)).ok(); - headers.append(b"authorization", &**print_buf); - print_buf.clear(); + if include_auth { + if !registry.token.is_empty() { + write!(print_buf, "Bearer {}", bstr::BStr::new(®istry.token)).ok(); + headers.append(b"authorization", &**print_buf); + print_buf.clear(); + } else if !registry.auth.is_empty() { + write!(print_buf, "Basic {}", bstr::BStr::new(®istry.auth)).ok(); + headers.append(b"authorization", &**print_buf); + print_buf.clear(); + } } if maybe_json_len.is_some() { @@ -2021,7 +2032,9 @@ impl PublishCommand { print_buf.clear(); headers.append(b"Connection", b"keep-alive"); - headers.append(b"Host", registry.url.url().host); + if include_auth { + headers.append(b"Host", registry.url.url().host); + } if let Some(json_len) = maybe_json_len { write!(print_buf, "{}", json_len).ok(); diff --git a/test/cli/install/bun-publish.test.ts b/test/cli/install/bun-publish.test.ts index 20cdb2085370..0b35bbb61fbb 100644 --- a/test/cli/install/bun-publish.test.ts +++ b/test/cli/install/bun-publish.test.ts @@ -175,7 +175,7 @@ describe("otp", async () => { const { packageDir, packageJson } = await registry.createTestDir(); const token = await registry.generateUser("otp-classic-fallback", "otp"); - let doneHits = 0; + const doneAuthHeaders: (string | null)[] = []; using mockRegistry = Bun.serve({ port: 0, fetch(req: Request) { @@ -192,7 +192,7 @@ describe("otp", async () => { ); } if (req.url.endsWith("done")) { - doneHits++; + doneAuthHeaders.push(req.headers.get("authorization")); return new Response(JSON.stringify({ token }), { status: 200 }); } return new Response("unexpected url", { status: 500 }); @@ -234,29 +234,36 @@ describe("otp", async () => { expect(out).toContain("customapp://login"); expect(out).not.toContain("open in browser"); expect(out).not.toContain("Enter OTP: "); - expect(doneHits).toBeGreaterThan(0); + // same-origin done url polling keeps the registry's credentials attached + expect(doneAuthHeaders).toEqual([`Bearer ${token}`]); expect(out).toContain(" + otp-pkg-5@5.5.5"); expect(exitCode).toBe(0); }); - test("done url on a different origin is not polled and login falls back to the OTP prompt", async () => { + test("done url on a different origin is polled without credentials", async () => { const packageDir = tmpdirSync(); const otpCode = "424242"; + const registryToken = "registry-secret-token"; - let foreignDoneHits = 0; + const foreignDoneRequests: { authorization: string | null; host: string | null }[] = []; using foreign = Bun.serve({ port: 0, - fetch() { - foreignDoneHits++; + fetch(req: Request) { + foreignDoneRequests.push({ + authorization: req.headers.get("authorization"), + host: req.headers.get("host"), + }); return new Response(JSON.stringify({ token: otpCode }), { status: 200 }); }, }); let localDoneHits = 0; + const registryAuthHeaders: (string | null)[] = []; using mockRegistry = Bun.serve({ port: 0, fetch(req: Request) { if (req.method === "PUT") { + registryAuthHeaders.push(req.headers.get("authorization")); if (req.headers.get("npm-otp") === otpCode) { return new Response("OK", { status: 200 }); } @@ -279,7 +286,7 @@ describe("otp", async () => { await Promise.all([ write( join(packageDir, "bunfig.toml"), - `[install]\ncache = false\nregistry = { url = "http://localhost:${mockRegistry.port}", token = "unused" }\n`, + `[install]\ncache = false\nregistry = { url = "http://localhost:${mockRegistry.port}", token = "${registryToken}" }\n`, ), write(join(packageDir, "package.json"), JSON.stringify({ name: "otp-pkg-6", version: "6.6.6" })), ]); @@ -289,15 +296,18 @@ describe("otp", async () => { cwd: packageDir, stdout: "pipe", stderr: "pipe", - stdin: Buffer.from(otpCode + "\n"), + stdin: "ignore", env, }); const [out, err, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); - expect(out).toContain("Enter OTP: "); - expect(out).not.toContain("Authenticate your account at"); - expect(foreignDoneHits).toBe(0); + expect(out).toContain("Authenticate your account at"); + expect(out).not.toContain("Enter OTP: "); + // the cross-origin done url must be polled, without the registry's credentials + expect(foreignDoneRequests).toEqual([{ authorization: null, host: `127.0.0.1:${foreign.port}` }]); + // the registry itself still receives credentials on both PUTs + expect(registryAuthHeaders).toEqual([`Bearer ${registryToken}`, `Bearer ${registryToken}`]); expect(localDoneHits).toBe(0); expect(out).toContain(" + otp-pkg-6@6.6.6"); expect(exitCode).toBe(0); From 45f076aadce0d05a95b15f6e27ed7d0aa771bb4d Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 31 Jul 2026 11:06:07 +0000 Subject: [PATCH 2/3] ci: retrigger From c3b1bce587ffedd61c3a93b2a7cc98b894e0c130 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 31 Jul 2026 11:06:43 +0000 Subject: [PATCH 3/3] drop redundant comment --- src/runtime/cli/publish_command.rs | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/runtime/cli/publish_command.rs b/src/runtime/cli/publish_command.rs index 079bdba88ac2..014de60d0f12 100644 --- a/src/runtime/cli/publish_command.rs +++ b/src/runtime/cli/publish_command.rs @@ -1189,8 +1189,6 @@ impl PublishCommand { if !(done_url.is_http() || done_url.is_https()) { break 'try_web; } - // npm-registry-fetch scopes credentials by registry: attach auth - // only when polling the registry's own origin. let done_url_same_origin = { let registry_url = registry.url.url(); done_url.is_https() == registry_url.is_https()