/** * Tests for sandboxHttpFetch — the bridge's outbound HTTP helper used by * sandboxed plugins. * * Two behaviours that need coverage: * - Redirects must re-validate against allowedHosts at every hop. The * native `fetch` follows 3xx responses automatically, so an allowed host * that 302s to a blocked host would otherwise bypass the allowlist. * - Credential headers (Authorization, Cookie, Proxy-Authorization) must * be stripped on cross-origin hops so they don't leak to attacker * destinations. * - With `network:request:unrestricted` (no allowlist), requests targeting literal * private IPs or known internal hostnames must still be rejected. */ import { afterEach, describe, expect, it, vi } from "vitest"; import { sandboxHttpFetch } from "../../src/sandbox/bridge-http.js"; function okResponse(body = "ok"): Response { return new Response(body, { status: 200 }); } function redirectResponse(location: string, status = 302): Response { return new Response(null, { status, headers: { Location: location } }); } type FetchImpl = NonNullable[2]["fetchImpl"]>; function mockFetchSequence(responses: Response[]): FetchImpl { const queue = [...responses]; return vi.fn(async () => { const next = queue.shift(); if (!next) throw new Error("fetch called more times than expected"); return next; // eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- vi.fn's generic signature doesn't line up with Workers' fetch type; cast to the injectable contract }) as unknown as FetchImpl; } afterEach(() => { vi.restoreAllMocks(); }); // --------------------------------------------------------------------------- // Capability gating // --------------------------------------------------------------------------- describe("sandboxHttpFetch — capability enforcement", () => { it("rejects when neither network:request nor network:request:unrestricted is held", async () => { await expect( sandboxHttpFetch("https://a.example.com/", undefined, { capabilities: [], allowedHosts: ["a.example.com"], fetchImpl: mockFetchSequence([okResponse()]), }), ).rejects.toThrow(/network:request/); }); it("allows when network:request is held and host is on the list", async () => { const res = await sandboxHttpFetch("https://a.example.com/", undefined, { capabilities: ["network:request"], allowedHosts: ["a.example.com"], fetchImpl: mockFetchSequence([okResponse()]), }); expect(res.status).toBe(200); }); it("allows when network:request:unrestricted is held and skips the allowlist for public hosts", async () => { const res = await sandboxHttpFetch("https://a.example.com/", undefined, { capabilities: ["network:request:unrestricted"], allowedHosts: [], fetchImpl: mockFetchSequence([okResponse()]), }); expect(res.status).toBe(200); }); }); // --------------------------------------------------------------------------- // Host allowlist enforcement per redirect hop // --------------------------------------------------------------------------- describe("sandboxHttpFetch — redirect allowlist enforcement", () => { it("rejects a redirect to a host not on the allowlist", async () => { await expect( sandboxHttpFetch("https://a.example.com/", undefined, { capabilities: ["network:request"], allowedHosts: ["a.example.com"], fetchImpl: mockFetchSequence([redirectResponse("https://evil.example.com/"), okResponse()]), }), ).rejects.toThrow(/not allowed|host/i); }); it("follows a redirect to a host that IS on the allowlist", async () => { const res = await sandboxHttpFetch("https://a.example.com/", undefined, { capabilities: ["network:request"], allowedHosts: ["a.example.com", "b.example.com"], fetchImpl: mockFetchSequence([ redirectResponse("https://b.example.com/next"), okResponse("from-b"), ]), }); expect(res.status).toBe(200); expect(res.text).toBe("from-b"); }); it("rejects chains that exceed the redirect limit", async () => { // 6 redirects to the same allowed host — more than our max of 5 const fetchImpl = mockFetchSequence([ redirectResponse("https://a.example.com/1"), redirectResponse("https://a.example.com/2"), redirectResponse("https://a.example.com/3"), redirectResponse("https://a.example.com/4"), redirectResponse("https://a.example.com/5"), redirectResponse("https://a.example.com/6"), okResponse(), ]); await expect( sandboxHttpFetch("https://a.example.com/", undefined, { capabilities: ["network:request"], allowedHosts: ["a.example.com"], fetchImpl, }), ).rejects.toThrow(/too many redirects|redirect/i); }); }); // --------------------------------------------------------------------------- // Credential header stripping on cross-origin redirects // --------------------------------------------------------------------------- describe("sandboxHttpFetch — credential header stripping", () => { it("preserves credentials on same-origin redirect", async () => { const fetchImpl = mockFetchSequence([ redirectResponse("https://a.example.com/page2"), okResponse(), ]); await sandboxHttpFetch( "https://a.example.com/", { headers: { Authorization: "Bearer secret-token" }, }, { capabilities: ["network:request"], allowedHosts: ["a.example.com"], fetchImpl, }, ); // eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- vi.Mock type hygiene const secondCall = (fetchImpl as unknown as { mock: { calls: unknown[][] } }).mock.calls[1]; const init = secondCall?.[1] as RequestInit | undefined; const headers = new Headers(init?.headers); expect(headers.get("authorization")).toBe("Bearer secret-token"); }); it("strips Authorization on cross-origin redirect", async () => { const fetchImpl = mockFetchSequence([ redirectResponse("https://b.example.com/after"), okResponse(), ]); await sandboxHttpFetch( "https://a.example.com/", { headers: { Authorization: "Bearer secret-token" }, }, { capabilities: ["network:request"], allowedHosts: ["a.example.com", "b.example.com"], fetchImpl, }, ); // eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- vi.Mock type hygiene const secondCall = (fetchImpl as unknown as { mock: { calls: unknown[][] } }).mock.calls[1]; const init = secondCall?.[1] as RequestInit | undefined; const headers = new Headers(init?.headers); expect(headers.get("authorization")).toBeNull(); }); it("strips Cookie and Proxy-Authorization on cross-origin redirect", async () => { const fetchImpl = mockFetchSequence([ redirectResponse("https://b.example.com/after"), okResponse(), ]); await sandboxHttpFetch( "https://a.example.com/", { headers: { Cookie: "session=abc", "Proxy-Authorization": "Basic creds", }, }, { capabilities: ["network:request"], allowedHosts: ["a.example.com", "b.example.com"], fetchImpl, }, ); // eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- vi.Mock type hygiene const secondCall = (fetchImpl as unknown as { mock: { calls: unknown[][] } }).mock.calls[1]; const init = secondCall?.[1] as RequestInit | undefined; const headers = new Headers(init?.headers); expect(headers.get("cookie")).toBeNull(); expect(headers.get("proxy-authorization")).toBeNull(); }); }); // --------------------------------------------------------------------------- // SSRF defence for network:request:unrestricted // --------------------------------------------------------------------------- describe("sandboxHttpFetch — SSRF defence with network:request:unrestricted", () => { it("rejects literal loopback IPv4", async () => { await expect( sandboxHttpFetch("http://127.0.0.1/", undefined, { capabilities: ["network:request:unrestricted"], allowedHosts: [], fetchImpl: mockFetchSequence([okResponse()]), }), ).rejects.toThrow(); }); it("rejects literal private IPv4 ranges", async () => { for (const url of [ "http://10.0.0.1/", "http://192.168.1.1/", "http://172.16.0.1/", "http://169.254.169.254/latest/meta-data/", ]) { await expect( sandboxHttpFetch(url, undefined, { capabilities: ["network:request:unrestricted"], allowedHosts: [], fetchImpl: mockFetchSequence([okResponse()]), }), ).rejects.toThrow(); } }); it("rejects localhost and metadata hostnames", async () => { for (const url of ["http://localhost/", "http://metadata.google.internal/"]) { await expect( sandboxHttpFetch(url, undefined, { capabilities: ["network:request:unrestricted"], allowedHosts: [], fetchImpl: mockFetchSequence([okResponse()]), }), ).rejects.toThrow(); } }); it("rejects IPv6 loopback", async () => { await expect( sandboxHttpFetch("http://[::1]/", undefined, { capabilities: ["network:request:unrestricted"], allowedHosts: [], fetchImpl: mockFetchSequence([okResponse()]), }), ).rejects.toThrow(); }); it("re-applies the SSRF check on redirects", async () => { // Public host redirects to a private IP — must be blocked. await expect( sandboxHttpFetch("https://public.example.com/", undefined, { capabilities: ["network:request:unrestricted"], allowedHosts: [], fetchImpl: mockFetchSequence([ redirectResponse("http://169.254.169.254/latest/meta-data/"), okResponse(), ]), }), ).rejects.toThrow(); }); // The WHATWG URL parser normalises IPv4-mapped IPv6 to hex form: // [::ffff:127.0.0.1] -> [::ffff:7f00:1] // [::ffff:169.254.169.254] -> [::ffff:a9fe:a9fe] // A literal-string check against "::ffff:127.0.0.1" never matches the // form the bridge actually sees. We must normalise the hex form back // to dotted-decimal before the range check. it("rejects IPv4-mapped IPv6 loopback in hex form", async () => { await expect( sandboxHttpFetch("http://[::ffff:7f00:1]/", undefined, { capabilities: ["network:request:unrestricted"], allowedHosts: [], fetchImpl: mockFetchSequence([okResponse()]), }), ).rejects.toThrow(); }); it("rejects IPv4-mapped IPv6 metadata address in hex form", async () => { await expect( sandboxHttpFetch("http://[::ffff:a9fe:a9fe]/latest/meta-data/", undefined, { capabilities: ["network:request:unrestricted"], allowedHosts: [], fetchImpl: mockFetchSequence([okResponse()]), }), ).rejects.toThrow(); }); it("rejects IPv4-mapped IPv6 private ranges in hex form", async () => { for (const url of [ "http://[::ffff:a00:1]/", // 10.0.0.1 "http://[::ffff:c0a8:1]/", // 192.168.0.1 "http://[::ffff:ac10:1]/", // 172.16.0.1 ]) { await expect( sandboxHttpFetch(url, undefined, { capabilities: ["network:request:unrestricted"], allowedHosts: [], fetchImpl: mockFetchSequence([okResponse()]), }), ).rejects.toThrow(); } }); }); // --------------------------------------------------------------------------- // SSRF defence applies even when the restricted path uses allowedHosts=["*"] // --------------------------------------------------------------------------- describe('sandboxHttpFetch — SSRF defence with allowedHosts=["*"]', () => { // A plugin with { capabilities: ["network:request"], allowedHosts: ["*"] } // gets full egress with zero SSRF protection unless we apply the literal // check on the restricted path too. The allowlist describes scope, not // safety. it("rejects literal private IPv4 even with allowedHosts=['*']", async () => { await expect( sandboxHttpFetch("http://127.0.0.1/", undefined, { capabilities: ["network:request"], allowedHosts: ["*"], fetchImpl: mockFetchSequence([okResponse()]), }), ).rejects.toThrow(); }); it("rejects cloud-metadata IP even with allowedHosts=['*']", async () => { await expect( sandboxHttpFetch("http://169.254.169.254/", undefined, { capabilities: ["network:request"], allowedHosts: ["*"], fetchImpl: mockFetchSequence([okResponse()]), }), ).rejects.toThrow(); }); it("rejects localhost even with allowedHosts=['*']", async () => { await expect( sandboxHttpFetch("http://localhost/", undefined, { capabilities: ["network:request"], allowedHosts: ["*"], fetchImpl: mockFetchSequence([okResponse()]), }), ).rejects.toThrow(); }); it("still allows public hosts with allowedHosts=['*']", async () => { const res = await sandboxHttpFetch("https://api.example.com/", undefined, { capabilities: ["network:request"], allowedHosts: ["*"], fetchImpl: mockFetchSequence([okResponse()]), }); expect(res.status).toBe(200); }); }); // --------------------------------------------------------------------------- // URL scheme enforcement // --------------------------------------------------------------------------- describe("sandboxHttpFetch — scheme enforcement", () => { it("rejects file: scheme", async () => { await expect( sandboxHttpFetch("file:///etc/passwd", undefined, { capabilities: ["network:request:unrestricted"], allowedHosts: [], fetchImpl: mockFetchSequence([okResponse()]), }), ).rejects.toThrow(/scheme/i); }); it("rejects data: scheme", async () => { await expect( sandboxHttpFetch("data:text/plain,secret", undefined, { capabilities: ["network:request:unrestricted"], allowedHosts: [], fetchImpl: mockFetchSequence([okResponse()]), }), ).rejects.toThrow(/scheme/i); }); it("rejects ftp: scheme", async () => { await expect( sandboxHttpFetch("ftp://example.com/file", undefined, { capabilities: ["network:request:unrestricted"], allowedHosts: [], fetchImpl: mockFetchSequence([okResponse()]), }), ).rejects.toThrow(/scheme/i); }); it("accepts http: and https:", async () => { for (const url of ["http://a.example.com/", "https://a.example.com/"]) { const res = await sandboxHttpFetch(url, undefined, { capabilities: ["network:request"], allowedHosts: ["a.example.com"], fetchImpl: mockFetchSequence([okResponse()]), }); expect(res.status).toBe(200); } }); }); // --------------------------------------------------------------------------- // Allowlist normalisation — trailing dots and mixed case // --------------------------------------------------------------------------- describe("sandboxHttpFetch — allowlist normalisation", () => { it("matches when the manifest uses mixed case", async () => { const res = await sandboxHttpFetch("https://api.example.com/", undefined, { capabilities: ["network:request"], allowedHosts: ["API.Example.COM"], fetchImpl: mockFetchSequence([okResponse()]), }); expect(res.status).toBe(200); }); it("matches when the request uses a trailing dot FQDN", async () => { const res = await sandboxHttpFetch("https://api.example.com./", undefined, { capabilities: ["network:request"], allowedHosts: ["api.example.com"], fetchImpl: mockFetchSequence([okResponse()]), }); expect(res.status).toBe(200); }); it("matches wildcard patterns case-insensitively", async () => { const res = await sandboxHttpFetch("https://api.example.com/", undefined, { capabilities: ["network:request"], allowedHosts: ["*.Example.COM"], fetchImpl: mockFetchSequence([okResponse()]), }); expect(res.status).toBe(200); }); }); // --------------------------------------------------------------------------- // *.localhost hostnames // --------------------------------------------------------------------------- describe("sandboxHttpFetch — *.localhost", () => { // RFC 6761 reserves .localhost for loopback. Subdomains of localhost // must be treated as internal too. it("rejects app.localhost", async () => { await expect( sandboxHttpFetch("http://app.localhost/", undefined, { capabilities: ["network:request:unrestricted"], allowedHosts: [], fetchImpl: mockFetchSequence([okResponse()]), }), ).rejects.toThrow(); }); it("rejects nested *.localhost subdomains", async () => { await expect( sandboxHttpFetch("http://admin.app.localhost/", undefined, { capabilities: ["network:request:unrestricted"], allowedHosts: [], fetchImpl: mockFetchSequence([okResponse()]), }), ).rejects.toThrow(); }); });