fix: sanitize rebuilt headers, refresh stale docs, cover the route (#2319 round 7)

Three findings, all real.

`arrayBuffer()` hands back the decoded entity body, so the rebuilt
response was carrying the upstream `content-encoding` and the
compressed `content-length` — a gzipped metadata document came back as
plain JSON bytes still claiming gzip and the wrong size, visible in the
Network capture. Both headers are deleted now, as `responseWithBody` in
endpointOverrides.ts already did for the same reason.

The "why a race, and not just the signal" rationale described a world
this PR had since changed: the signal does now reach the backend's
outbound fetch. The module doc and three implementation comments are
rewritten to say what the race is actually still for — a fetchFn that
ignores AbortSignal, and a backend that is itself wedged — rather than
a proxy hop that no longer drops it.

The backend cancellation path had only mock-level coverage. A
route-level test drives /api/fetch at an upstream that accepts and never
answers, aborts the browser side, and asserts the upstream observes its
connection closing. Verified by mutation: dropping `signal` from the
route's outbound fetch makes it time out rather than pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YFnM6RqB4nXqNysgUYmjmq
Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-09-11 03:43:57 -04:00
commit f3e4023e35
2 changed files with 128 additions and 32 deletions

View file

@ -96,11 +96,12 @@ describe("withOAuthRequestTimeout", () => {
expect((await assertion) as Error).toBeInstanceOf(OAuthRequestTimeoutError);
});
it("still enforces the deadline when the signal is dropped in flight", async () => {
it("still enforces the deadline when the fetch ignores the signal", async () => {
vi.useFakeTimers();
// The browser's `createRemoteFetch` re-issues the call to `/api/fetch` and
// forwards no signal, so aborting locally cancels nothing. The race is what
// has to bound it.
// The signal now reaches all the way out — `createRemoteFetch` forwards it
// onto the proxy hop and `/api/fetch` composes it into its outbound call —
// but a `fetchFn` that ignores `AbortSignal` cancels nothing, so the race
// is what bounds this case.
const signalIgnoring: typeof fetch = () => new Promise<Response>(() => {});
const wrapped = withOAuthRequestTimeout(signalIgnoring, 1000);
@ -127,9 +128,9 @@ describe("withOAuthRequestTimeout", () => {
});
it("races caller cancellation too, so it wins on a signal-ignoring fetch", async () => {
// Forwarding alone is not enough on the browser's `createRemoteFetch` path,
// which drops the signal: without the race the caller's abort would sit
// pending for the whole budget instead of winning.
// Forwarding alone is not enough against a `fetchFn` that ignores the
// signal: without the race the caller's abort would sit pending for the
// whole budget instead of winning.
const wrapped = withOAuthRequestTimeout(neverSettles, 60_000);
const caller = new AbortController();
@ -148,8 +149,8 @@ describe("withOAuthRequestTimeout", () => {
await expect(
wrapped(URL_UNDER_TEST, { signal: AbortSignal.abort(reason) }),
).rejects.toBe(reason);
// Racing it would still have evaluated the inner fetch, which on the
// signal-dropping proxy path means the request actually goes out.
// Racing it would still have evaluated the inner fetch, and a fetch that
// does not check an already-aborted signal would send the request.
expect(inner).not.toHaveBeenCalled();
});
@ -257,6 +258,8 @@ describe("withOAuthRequestTimeout", () => {
expect(response.statusText).toBe("Created");
expect(response.ok).toBe(true);
expect(response.headers.get("x-probe")).toBe("kept");
expect(response.headers.get("content-encoding")).toBeNull();
expect(response.headers.get("content-length")).toBeNull();
expect(response.url).toBe(original.url);
expect(response.redirected).toBe(original.redirected);
expect(response.type).toBe(original.type);
@ -265,6 +268,33 @@ describe("withOAuthRequestTimeout", () => {
});
});
it("drops content-encoding and content-length, which the buffer invalidates", async () => {
// `arrayBuffer()` hands back the *decoded* entity body, so an inherited
// `content-encoding` would describe an encoding the bytes no longer have
// and the inherited `content-length` would be the compressed size. Both are
// wrong, and both show up in the Network capture.
const decoded = '{"issuer":"https://as.example.com"}';
const wrapped = withOAuthRequestTimeout(
vi.fn<typeof fetch>().mockResolvedValue(
new Response(decoded, {
headers: {
"content-type": "application/json",
"content-encoding": "gzip",
"content-length": "42",
},
}),
),
1000,
);
const response = await wrapped(URL_UNDER_TEST);
expect(response.headers.get("content-encoding")).toBeNull();
expect(response.headers.get("content-length")).toBeNull();
expect(response.headers.get("content-type")).toBe("application/json");
await expect(response.text()).resolves.toBe(decoded);
});
it("rebuilds a null-body status without handing it a body", async () => {
// `new Response(body, { status: 204 })` throws; the rebuild has to pass null.
const wrapped = withOAuthRequestTimeout(

View file

@ -300,6 +300,10 @@ describe("server.ts supplemental coverage", () => {
let h: Harness;
let target: ServerType;
let targetUrl: string;
/** Resolves when an upstream `/stall` request is closed by its client. */
let stallClosed: Promise<void>;
/** Paths the upstream fixture has actually received. */
let upstreamHits: string[];
// Isolate the WHOLE suite from the developer's (or CI's) proxy environment.
// Setting only uppercase HTTP_PROXY in the one proxy test would not be
@ -336,7 +340,21 @@ describe("server.ts supplemental coverage", () => {
h = await start();
// A tiny upstream HTTP server we can point /api/fetch at.
const { createServer } = await import("node:http");
let markClosed: () => void = () => {};
stallClosed = new Promise<void>((resolve) => {
markClosed = resolve;
});
upstreamHits = [];
const srv = createServer((req, res) => {
upstreamHits.push(req.url ?? "");
if (req.url === "/stall") {
// Accept the request, answer nothing, and report when the client
// gives up — the #2319 wedged-authorization-server shape. `close` on
// the response fires whether the socket was torn down or the handler
// simply ended, and nothing here ever ends it.
res.on("close", () => markClosed());
return;
}
if (req.url === "/stream") {
res.writeHead(200, { "Content-Type": "text/event-stream" });
res.write("data: hi\n\n");
@ -381,6 +399,35 @@ describe("server.ts supplemental coverage", () => {
await stop(h);
});
it("releases the upstream request when the browser cancels (#2319)", async () => {
// The point of forwarding the signal through `createRemoteFetch`: without
// it the browser's abort settled only its own promise, and this handler
// plus the upstream socket stayed pending for as long as the server cared
// to stall — once per timed-out attempt. Asserted at the route rather
// than on a mock, because the mock cannot show the socket being released.
const caller = new AbortController();
const pending = fetch(`${h.baseUrl}/api/fetch`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ url: `${targetUrl}/stall` }),
signal: caller.signal,
});
const rejected = pending.catch((err: unknown) => err);
// Wait until the upstream has actually received the request, so the abort
// cannot race ahead of it and pass for the wrong reason.
await vi.waitFor(() => {
expect(upstreamHits).toContain("/stall");
});
caller.abort();
expect(await rejected).toBeInstanceOf(Error);
// The upstream sees its connection go away. Without the signal composed
// into the route's outbound fetch this never resolves and the test times
// out.
await stallClosed;
});
it("routes the outbound request through HTTP_PROXY (#2067)", async () => {
// /api/fetch is the browser's ONLY way out to the network — the web
// client's `environment.fetch` is `createRemoteFetch()`, which forwards