From b959ea0f598c3be876fb7a0ef22d74a79842583b Mon Sep 17 00:00:00 2001 From: Jeff Repanich Date: Sun, 16 Aug 2026 09:56:21 -0400 Subject: [PATCH 1/2] fix: cancel discarded redirect bodies --- src/client.ts | 18 ++++++++++++++-- tests/testing.test.ts | 50 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+), 2 deletions(-) diff --git a/src/client.ts b/src/client.ts index 24cd173..d441de2 100644 --- a/src/client.ts +++ b/src/client.ts @@ -36,6 +36,14 @@ function mergeCookieHeader(headers: Headers, jarValue: string): void { const redirectStatuses = new Set([301, 302, 303, 307, 308]); const sensitiveHeaders = ["authorization", "cookie", "proxy-authorization"]; +async function discardResponseBody(response: Response): Promise { + try { + await response.body?.cancel(); + } catch { + // A discarded body must not replace the redirect result with a cleanup error. + } +} + async function run( target: Injectable, initial: Request, @@ -51,12 +59,18 @@ async function run( const response = await dispatch(target, dispatched); if (jar) await captureCookies(jar, response, dispatched.url); if (!redirectStatuses.has(response.status) || request.redirect === "manual") return response; - if (request.redirect === "error") + if (request.redirect === "error") { + await discardResponseBody(response); throw new TypeError("Redirect encountered with redirect mode 'error'"); + } const location = response.headers.get("location"); if (!location) return response; - if (hops++ >= maxRedirects) + if (hops++ >= maxRedirects) { + await discardResponseBody(response); throw new TypeError(`Maximum redirect count of ${maxRedirects} exceeded`); + } + + await discardResponseBody(response); const nextUrl = new URL(location, request.url); const headers = new Headers(request.headers); diff --git a/tests/testing.test.ts b/tests/testing.test.ts index ff6a5f0..f4dd44c 100644 --- a/tests/testing.test.ts +++ b/tests/testing.test.ts @@ -161,6 +161,56 @@ describe("redirects", () => { return new Response("done"); }; + it.each([ + { redirect: "follow", maxRedirects: 10, rejects: false }, + { redirect: "error", maxRedirects: 10, rejects: true }, + { redirect: "follow", maxRedirects: 0, rejects: true }, + ] as const)( + "should cancel discarded redirect bodies for $redirect with limit $maxRedirects", + async ({ redirect, maxRedirects, rejects }) => { + let cancelled = 0; + const request = inject( + (incoming) => + new URL(incoming.url).pathname === "/start" + ? new Response( + new ReadableStream({ + cancel() { + cancelled += 1; + }, + }), + { status: 302, headers: { location: "/end" } }, + ) + : new Response("done"), + "/start", + { redirect, maxRedirects }, + ); + + if (rejects) await expect(request).rejects.toBeInstanceOf(TypeError); + else expect(await (await request).text()).toBe("done"); + expect(cancelled).toBe(1); + }, + ); + + it("should preserve redirect traversal when discarded-body cancellation fails", async () => { + const response = await inject( + (incoming) => + new URL(incoming.url).pathname === "/start" + ? new Response( + new ReadableStream({ + cancel() { + throw new Error("cleanup failed"); + }, + }), + { status: 302, headers: { location: "/end" } }, + ) + : new Response("done"), + "/start", + { redirect: "follow" }, + ); + + expect(await response.text()).toBe("done"); + }); + it("should be manual by default and follow with standard rewriting", async () => { expect((await inject(redirects, "/start", { method: "POST", body: "value" })).status).toBe(302); seen.length = 0; From 2bfa2359ce923530d3ea0cac6bf822d90e05eef2 Mon Sep 17 00:00:00 2001 From: Jeff Repanich Date: Sun, 16 Aug 2026 09:56:59 -0400 Subject: [PATCH 2/2] fix: avoid blocking on redirect cleanup --- src/client.ts | 14 ++++++-------- tests/testing.test.ts | 18 ++++++++++++++++++ 2 files changed, 24 insertions(+), 8 deletions(-) diff --git a/src/client.ts b/src/client.ts index d441de2..1013195 100644 --- a/src/client.ts +++ b/src/client.ts @@ -36,12 +36,10 @@ function mergeCookieHeader(headers: Headers, jarValue: string): void { const redirectStatuses = new Set([301, 302, 303, 307, 308]); const sensitiveHeaders = ["authorization", "cookie", "proxy-authorization"]; -async function discardResponseBody(response: Response): Promise { - try { - await response.body?.cancel(); - } catch { +function discardResponseBody(response: Response): void { + void response.body?.cancel().catch(() => { // A discarded body must not replace the redirect result with a cleanup error. - } + }); } async function run( @@ -60,17 +58,17 @@ async function run( if (jar) await captureCookies(jar, response, dispatched.url); if (!redirectStatuses.has(response.status) || request.redirect === "manual") return response; if (request.redirect === "error") { - await discardResponseBody(response); + discardResponseBody(response); throw new TypeError("Redirect encountered with redirect mode 'error'"); } const location = response.headers.get("location"); if (!location) return response; if (hops++ >= maxRedirects) { - await discardResponseBody(response); + discardResponseBody(response); throw new TypeError(`Maximum redirect count of ${maxRedirects} exceeded`); } - await discardResponseBody(response); + discardResponseBody(response); const nextUrl = new URL(location, request.url); const headers = new Headers(request.headers); diff --git a/tests/testing.test.ts b/tests/testing.test.ts index f4dd44c..19b46bc 100644 --- a/tests/testing.test.ts +++ b/tests/testing.test.ts @@ -211,6 +211,24 @@ describe("redirects", () => { expect(await response.text()).toBe("done"); }); + it("should not wait for discarded-body cancellation to settle", async () => { + const response = await inject( + (incoming) => + new URL(incoming.url).pathname === "/start" + ? new Response( + new ReadableStream({ + cancel: () => new Promise(() => undefined), + }), + { status: 302, headers: { location: "/end" } }, + ) + : new Response("done"), + "/start", + { redirect: "follow" }, + ); + + expect(await response.text()).toBe("done"); + }); + it("should be manual by default and follow with standard rewriting", async () => { expect((await inject(redirects, "/start", { method: "POST", body: "value" })).status).toBe(302); seen.length = 0;