diff --git a/src/browser/client-fetch.loopback-auth.test.ts b/src/browser/client-fetch.loopback-auth.test.ts index f0c49b3163d..209f87d9fd0 100644 --- a/src/browser/client-fetch.loopback-auth.test.ts +++ b/src/browser/client-fetch.loopback-auth.test.ts @@ -31,6 +31,18 @@ vi.mock("./routes/dispatcher.js", () => ({ import { fetchBrowserJson } from "./client-fetch.js"; +function stubJsonFetchOk() { + const fetchMock = vi.fn<(input: RequestInfo | URL, init?: RequestInit) => Promise>( + async () => + new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); + vi.stubGlobal("fetch", fetchMock); + return fetchMock; +} + describe("fetchBrowserJson loopback auth", () => { beforeEach(() => { vi.restoreAllMocks(); @@ -49,14 +61,7 @@ describe("fetchBrowserJson loopback auth", () => { }); it("adds bearer auth for loopback absolute HTTP URLs", async () => { - const fetchMock = vi.fn<(input: RequestInfo | URL, init?: RequestInit) => Promise>( - async () => - new Response(JSON.stringify({ ok: true }), { - status: 200, - headers: { "Content-Type": "application/json" }, - }), - ); - vi.stubGlobal("fetch", fetchMock); + const fetchMock = stubJsonFetchOk(); const res = await fetchBrowserJson<{ ok: boolean }>("http://127.0.0.1:18888/"); expect(res.ok).toBe(true); @@ -67,14 +72,7 @@ describe("fetchBrowserJson loopback auth", () => { }); it("does not inject auth for non-loopback absolute URLs", async () => { - const fetchMock = vi.fn<(input: RequestInfo | URL, init?: RequestInit) => Promise>( - async () => - new Response(JSON.stringify({ ok: true }), { - status: 200, - headers: { "Content-Type": "application/json" }, - }), - ); - vi.stubGlobal("fetch", fetchMock); + const fetchMock = stubJsonFetchOk(); await fetchBrowserJson<{ ok: boolean }>("http://example.com/"); @@ -84,14 +82,7 @@ describe("fetchBrowserJson loopback auth", () => { }); it("keeps caller-supplied auth header", async () => { - const fetchMock = vi.fn<(input: RequestInfo | URL, init?: RequestInit) => Promise>( - async () => - new Response(JSON.stringify({ ok: true }), { - status: 200, - headers: { "Content-Type": "application/json" }, - }), - ); - vi.stubGlobal("fetch", fetchMock); + const fetchMock = stubJsonFetchOk(); await fetchBrowserJson<{ ok: boolean }>("http://localhost:18888/", { headers: { @@ -103,4 +94,14 @@ describe("fetchBrowserJson loopback auth", () => { const headers = new Headers(init?.headers); expect(headers.get("authorization")).toBe("Bearer caller-token"); }); + + it("injects auth for IPv6 loopback absolute URLs", async () => { + const fetchMock = stubJsonFetchOk(); + + await fetchBrowserJson<{ ok: boolean }>("http://[::1]:18888/"); + + const init = fetchMock.mock.calls[0]?.[1]; + const headers = new Headers(init?.headers); + expect(headers.get("authorization")).toBe("Bearer loopback-token"); + }); }); diff --git a/src/browser/client-fetch.ts b/src/browser/client-fetch.ts index c8617d0f79c..2fc0bacf396 100644 --- a/src/browser/client-fetch.ts +++ b/src/browser/client-fetch.ts @@ -21,7 +21,11 @@ function isAbsoluteHttp(url: string): boolean { function isLoopbackHttpUrl(url: string): boolean { try { const host = new URL(url).hostname.trim().toLowerCase(); - return host === "127.0.0.1" || host === "localhost" || host === "::1"; + // URL hostnames may keep IPv6 brackets (for example "[::1]"); normalize before checks. + const normalizedHost = host.startsWith("[") && host.endsWith("]") ? host.slice(1, -1) : host; + return ( + normalizedHost === "127.0.0.1" || normalizedHost === "localhost" || normalizedHost === "::1" + ); } catch { return false; }