Skip to content

Commit 6f172d3

Browse files
authored
fix(web): reject 2xx responses whose body is not JSON (#194)
## Summary - `readJson` no longer collapses a failed JSON parse into `null` typed as the payload - a 2xx response whose body has bytes that are not JSON now raises `ApiError` with the status - the tolerant paths that were intentional stay: non-JSON error bodies still fall back to the status message, and an empty 2xx body still resolves to `null` ## Why ```ts const payload = (await response.json().catch(() => null)) as unknown; if (!response.ok) { throw new ApiError(...); } return payload as T; // null, typed as T ``` The `catch(() => null)` exists so that an error response carrying HTML or an empty body still produces a usable `ApiError`. But it applies to successful responses too, so any transport-level corruption under a 200 is handed back as `null` wearing the payload's type. Nothing fails at the boundary; the app crashes later, wherever a caller first reads a property. That is not hypothetical. #193 fixes a Cloudflare deployment where the Workers runtime re-encoded an already-gzipped body, so `/api/auth/session` returned 200 with `1f 8b` bytes. The symptom operators actually saw was a `TypeError` reading `authenticated` — several frames away from the cause, with a green network tab. This is independent of #193 and does not overlap with it: that PR stops producing the bad body, this one stops the client from disguising a bad body as a valid payload. ## Notes - The body is read once via `response.text()` and parsed locally, so the 2xx and non-2xx paths can differ. `parseJson` returns `undefined` for a non-JSON body, which `JSON.parse` can never produce for a valid one. - No `/api/*` endpoint returns an empty 2xx body today (every `DELETE` handler responds with JSON), but empty bodies keep resolving to `null` so a future `204` is not a behavior change. ## Validation - `npx vitest run` — 57 files / 528 tests pass - new `web/src/api.test.ts` covers all five paths; the malformed-2xx case fails against the previous implementation (`expected null to be an instance of ApiError`) - `npm run lint`, `oxfmt --check .`, `tsc -p src/tsconfig.json --noEmit`, and `tsc -p web/tsconfig.json --noEmit` are clean
1 parent 575a992 commit 6f172d3

2 files changed

Lines changed: 70 additions & 1 deletion

File tree

web/src/api.test.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { afterEach, describe, expect, it, vi } from "vitest";
2+
import { apiDelete, ApiError, apiGet } from "./api";
3+
4+
describe("readJson", () => {
5+
afterEach(() => {
6+
vi.unstubAllGlobals();
7+
});
8+
9+
it("returns the parsed payload for successful responses", async () => {
10+
stubFetch(Response.json({ authenticated: true }));
11+
12+
await expect(apiGet("/api/auth/session")).resolves.toEqual({ authenticated: true });
13+
});
14+
15+
it("rejects successful responses whose body is not JSON", async () => {
16+
// A gzip payload reaching the client undecoded is the real-world case: the
17+
// status is 200, so nothing else in the app treats the response as failed.
18+
stubFetch(new Response(new Uint8Array([0x1f, 0x8b, 0x08, 0x00]), { status: 200 }));
19+
20+
const error = await apiGet("/api/auth/session").catch((reason: unknown) => reason);
21+
expect(error).toBeInstanceOf(ApiError);
22+
expect((error as ApiError).status).toBe(200);
23+
expect((error as ApiError).message).toMatch(/not JSON/);
24+
});
25+
26+
it("keeps returning null for successful responses with an empty body", async () => {
27+
stubFetch(new Response(null, { status: 204 }));
28+
29+
await expect(apiDelete("/api/runtime-tokens/token-1")).resolves.toBeNull();
30+
});
31+
32+
it("reports the server error message when the failed body is JSON", async () => {
33+
stubFetch(Response.json({ errorMessage: "Connection not found." }, { status: 404 }));
34+
35+
await expect(apiGet("/api/connections/example")).rejects.toThrow("Connection not found.");
36+
});
37+
38+
it("falls back to the status when the failed body is not JSON", async () => {
39+
stubFetch(new Response("<html>502</html>", { status: 502 }));
40+
41+
await expect(apiGet("/api/providers")).rejects.toThrow("Request failed with 502");
42+
});
43+
});
44+
45+
function stubFetch(response: Response): void {
46+
vi.stubGlobal(
47+
"fetch",
48+
vi.fn(async () => response),
49+
);
50+
}

web/src/api.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,13 +65,32 @@ function headersFor(options: RequestOptions, json = false): Headers {
6565
}
6666

6767
async function readJson<T>(response: Response): Promise<T> {
68-
const payload = (await response.json().catch(() => null)) as unknown;
68+
const payload = parseJson(await response.text());
6969
if (!response.ok) {
7070
throw new ApiError(response.status, errorMessage(payload) ?? `Request failed with ${response.status}`);
7171
}
72+
// A successful response whose body is not JSON means something rewrote it in
73+
// transit. Returning the failed parse as T would hand the caller a null typed
74+
// as the payload, and the first property read off it crashes far from the
75+
// cause; a compressing proxy did exactly that to the whole dashboard once.
76+
if (payload === undefined) {
77+
throw new ApiError(response.status, `Request succeeded with ${response.status} but the response body was not JSON`);
78+
}
7279
return payload as T;
7380
}
7481

82+
/** Returns `undefined` for a body that is not JSON. `JSON.parse` never does. */
83+
function parseJson(body: string): unknown {
84+
if (body === "") {
85+
return null;
86+
}
87+
try {
88+
return JSON.parse(body) as unknown;
89+
} catch {
90+
return undefined;
91+
}
92+
}
93+
7594
function errorMessage(payload: unknown): string | undefined {
7695
if (!payload || typeof payload !== "object") {
7796
return undefined;

0 commit comments

Comments
 (0)