diff --git a/extensions/browser/src/browser/cdp.helpers.test.ts b/extensions/browser/src/browser/cdp.helpers.test.ts index 692ae5d73aa..a275fa5b546 100644 --- a/extensions/browser/src/browser/cdp.helpers.test.ts +++ b/extensions/browser/src/browser/cdp.helpers.test.ts @@ -51,6 +51,15 @@ describe("cdp helpers", () => { ).resolves.toBeUndefined(); }); + it("still enforces hostname allowlist for loopback CDP endpoints", async () => { + await expect( + assertCdpEndpointAllowed("http://127.0.0.1:9222/json/version", { + dangerouslyAllowPrivateNetwork: false, + hostnameAllowlist: ["*.corp.example"], + }), + ).rejects.toThrow("browser endpoint blocked by policy"); + }); + it("releases guarded CDP fetches for bodyless requests", async () => { const release = vi.fn(async () => {}); fetchWithSsrFGuardMock.mockResolvedValueOnce({ @@ -98,4 +107,34 @@ describe("cdp helpers", () => { ); expect(release).toHaveBeenCalledTimes(1); }); + + it("preserves hostname allowlist while allowing exact loopback CDP fetches", async () => { + const release = vi.fn(async () => {}); + fetchWithSsrFGuardMock.mockResolvedValueOnce({ + response: { + ok: true, + status: 200, + }, + release, + }); + + await expect( + fetchOk("http://127.0.0.1:9222/json/version", 250, undefined, { + dangerouslyAllowPrivateNetwork: false, + hostnameAllowlist: ["*.corp.example"], + }), + ).resolves.toBeUndefined(); + + expect(fetchWithSsrFGuardMock).toHaveBeenCalledWith( + expect.objectContaining({ + url: "http://127.0.0.1:9222/json/version", + policy: { + dangerouslyAllowPrivateNetwork: false, + hostnameAllowlist: ["*.corp.example"], + allowedHostnames: ["127.0.0.1"], + }, + }), + ); + expect(release).toHaveBeenCalledTimes(1); + }); }); diff --git a/extensions/browser/src/browser/cdp.helpers.ts b/extensions/browser/src/browser/cdp.helpers.ts index 1b04b2c7cc0..0b777a54029 100644 --- a/extensions/browser/src/browser/cdp.helpers.ts +++ b/extensions/browser/src/browser/cdp.helpers.ts @@ -68,14 +68,17 @@ export async function assertCdpEndpointAllowed( if (!["http:", "https:", "ws:", "wss:"].includes(parsed.protocol)) { throw new Error(`Invalid CDP URL protocol: ${parsed.protocol.replace(":", "")}`); } - // Loopback CDP endpoints are internal browser-control hops, not - // agent-controlled navigation targets. - if (isLoopbackHost(parsed.hostname)) { - return; - } try { + const policy = isLoopbackHost(parsed.hostname) + ? { + ...ssrfPolicy, + allowedHostnames: Array.from( + new Set([...(ssrfPolicy?.allowedHostnames ?? []), parsed.hostname]), + ), + } + : ssrfPolicy; await resolvePinnedHostnameWithPolicy(parsed.hostname, { - policy: ssrfPolicy, + policy, }); } catch (error) { throw new BrowserCdpEndpointBlockedError({ cause: error });