diff --git a/src/agents/tools/web-fetch.ssrf.test.ts b/src/agents/tools/web-fetch.ssrf.test.ts index dd79e7bcc7e5..e9321b9a5b2b 100644 --- a/src/agents/tools/web-fetch.ssrf.test.ts +++ b/src/agents/tools/web-fetch.ssrf.test.ts @@ -16,7 +16,7 @@ function redirectResponse(location: string): Response { ok: false, status: 302, headers: makeFetchHeaders({ location }), - body: { cancel: vi.fn() }, + body: { cancel: vi.fn(async () => undefined) }, } as unknown as Response; } diff --git a/src/infra/net/fetch-guard.ssrf.test.ts b/src/infra/net/fetch-guard.ssrf.test.ts index fca2821be2e9..33c983bd6baa 100644 --- a/src/infra/net/fetch-guard.ssrf.test.ts +++ b/src/infra/net/fetch-guard.ssrf.test.ts @@ -949,6 +949,57 @@ describe("fetchWithSsrFGuard hardening", () => { await result.release(); }); + it("preserves redirects when response body cancellation rejects", async () => { + const unhandledRejections: unknown[] = []; + const onUnhandledRejection = (reason: unknown) => { + unhandledRejections.push(reason); + }; + const cancel = vi.fn(() => { + throw new Error("redirect cancellation failed"); + }); + const fetchImpl = vi + .fn() + .mockResolvedValueOnce( + new Response(new ReadableStream({ cancel }), { + status: 302, + headers: { location: "https://cdn.example.com/asset" }, + }), + ) + .mockResolvedValueOnce(okResponse("redirected")); + process.on("unhandledRejection", onUnhandledRejection); + let result: Awaited> | undefined; + + try { + result = await fetchWithSsrFGuard({ + url: "https://api.example.com/start", + fetchImpl, + lookupFn: createPublicLookup(), + }); + + const reader = result.response.body?.getReader(); + if (!reader) { + throw new Error("expected redirected response body"); + } + try { + const firstChunk = await reader.read(); + expect(firstChunk.done).toBe(false); + expect(new TextDecoder().decode(firstChunk.value)).toBe("redirected"); + await expect(reader.read()).resolves.toEqual({ done: true, value: undefined }); + } finally { + reader.releaseLock(); + } + expect(cancel).toHaveBeenCalledOnce(); + await new Promise((resolve) => { + setImmediate(resolve); + }); + expect(unhandledRejections).toStrictEqual([]); + } finally { + await result?.release(); + process.off("unhandledRejection", onUnhandledRejection); + expect(process.listeners("unhandledRejection")).not.toContain(onUnhandledRejection); + } + }); + it("strips sensitive headers when redirect crosses origins", async () => { const lookupFn = createPublicLookup(); const fetchImpl = vi diff --git a/src/infra/net/fetch-guard.ts b/src/infra/net/fetch-guard.ts index d0a42a735240..5921cfcba169 100644 --- a/src/infra/net/fetch-guard.ts +++ b/src/infra/net/fetch-guard.ts @@ -698,7 +698,7 @@ async function fetchWithSsrFGuardInternal( throw new Error("Redirect loop detected"); } visited.add(nextVisitKey); - void response.body?.cancel(); + void response.body?.cancel().catch(() => undefined); await closeDispatcher(dispatcher); currentUrl = nextUrl; continue;