diff --git a/src/infra/net/fetch-guard.ssrf.test.ts b/src/infra/net/fetch-guard.ssrf.test.ts index 1817cc7e7d6..f90df5271f1 100644 --- a/src/infra/net/fetch-guard.ssrf.test.ts +++ b/src/infra/net/fetch-guard.ssrf.test.ts @@ -13,6 +13,34 @@ function okResponse(body = "ok"): Response { return new Response(body, { status: 200 }); } +function getSecondRequestHeaders(fetchImpl: ReturnType): Headers { + const [, secondInit] = fetchImpl.mock.calls[1] as [string, RequestInit]; + return new Headers(secondInit.headers); +} + +async function expectRedirectFailure(params: { + url: string; + responses: Response[]; + expectedError: RegExp; + lookupFn?: NonNullable[0]["lookupFn"]>; + maxRedirects?: number; +}) { + const fetchImpl = vi.fn(); + for (const response of params.responses) { + fetchImpl.mockResolvedValueOnce(response); + } + + await expect( + fetchWithSsrFGuard({ + url: params.url, + fetchImpl, + ...(params.lookupFn ? { lookupFn: params.lookupFn } : {}), + ...(params.maxRedirects === undefined ? {} : { maxRedirects: params.maxRedirects }), + }), + ).rejects.toThrow(params.expectedError); + return fetchImpl; +} + describe("fetchWithSsrFGuard hardening", () => { type LookupFn = NonNullable[0]["lookupFn"]>; const CROSS_ORIGIN_REDIRECT_STRIPPED_HEADERS = [ @@ -33,11 +61,6 @@ describe("fetchWithSsrFGuard hardening", () => { const createPublicLookup = (): LookupFn => vi.fn(async () => [{ address: "93.184.216.34", family: 4 }]) as unknown as LookupFn; - const getSecondRequestHeaders = (fetchImpl: ReturnType): Headers => { - const [, secondInit] = fetchImpl.mock.calls[1] as [string, RequestInit]; - return new Headers(secondInit.headers); - }; - async function runProxyModeDispatcherTest(params: { mode: (typeof GUARDED_FETCH_MODE)[keyof typeof GUARDED_FETCH_MODE]; expectEnvProxy: boolean; @@ -112,15 +135,12 @@ describe("fetchWithSsrFGuard hardening", () => { it("blocks redirect chains that hop to private hosts", async () => { const lookupFn = createPublicLookup(); - const fetchImpl = vi.fn().mockResolvedValueOnce(redirectResponse("http://127.0.0.1:6379/")); - - await expect( - fetchWithSsrFGuard({ - url: "https://public.example/start", - fetchImpl, - lookupFn, - }), - ).rejects.toThrow(/private|internal|blocked/i); + const fetchImpl = await expectRedirectFailure({ + url: "https://public.example/start", + responses: [redirectResponse("http://127.0.0.1:6379/")], + expectedError: /private|internal|blocked/i, + lookupFn, + }); expect(fetchImpl).toHaveBeenCalledTimes(1); }); @@ -136,6 +156,18 @@ describe("fetchWithSsrFGuard hardening", () => { expect(fetchImpl).not.toHaveBeenCalled(); }); + it("does not let wildcard allowlists match the apex host", async () => { + const fetchImpl = vi.fn(); + await expect( + fetchWithSsrFGuard({ + url: "https://assets.example.com/pic.png", + fetchImpl, + policy: { hostnameAllowlist: ["*.assets.example.com"] }, + }), + ).rejects.toThrow(/allowlist/i); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + it("allows wildcard allowlisted hosts", async () => { const lookupFn = createPublicLookup(); const fetchImpl = vi.fn(async () => new Response("ok", { status: 200 })); @@ -211,6 +243,41 @@ describe("fetchWithSsrFGuard hardening", () => { await result.release(); }); + it.each([ + { + name: "rejects redirects without a location header", + responses: [new Response(null, { status: 302 })], + expectedError: /missing location header/i, + maxRedirects: undefined, + }, + { + name: "rejects redirect loops", + responses: [ + redirectResponse("https://public.example/next"), + redirectResponse("https://public.example/next"), + ], + expectedError: /redirect loop/i, + maxRedirects: undefined, + }, + { + name: "rejects too many redirects", + responses: [ + redirectResponse("https://public.example/one"), + redirectResponse("https://public.example/two"), + ], + expectedError: /too many redirects/i, + maxRedirects: 1, + }, + ])("$name", async ({ responses, expectedError, maxRedirects }) => { + await expectRedirectFailure({ + url: "https://public.example/start", + responses, + expectedError, + lookupFn: createPublicLookup(), + maxRedirects, + }); + }); + it("ignores env proxy by default to preserve DNS-pinned destination binding", async () => { await runProxyModeDispatcherTest({ mode: GUARDED_FETCH_MODE.STRICT,