Skip to content

Commit 5fa826b

Browse files
fix(api): stop returning stack traces to clients
The deployed public overlay API was returning `data.stack` — internal file paths and line numbers — in every error response. Sending a bad overlay token to `state.getPublic` was enough to read it, from an endpoint that is public by design because OBS can't authenticate through Cloudflare Access. tRPC attaches the stack unless it believes it's running in production, and a Worker's NODE_ENV is not a reliable signal, so an `errorFormatter` now strips it unconditionally rather than gating on an env check that could silently move. The full error still reaches the Worker logs, which is where diagnosis belongs. Everything a client legitimately renders is untouched: message, code, httpStatus, path, and the zodError payload. Verified through tRPC's real fetch handler, not just the pure helper — a formatter that isn't wired up would pass a unit test either way. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 63a263c commit 5fa826b

2 files changed

Lines changed: 76 additions & 1 deletion

File tree

packages/api/src/index.test.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { expect, test } from "bun:test";
2+
3+
import { stripErrorStack } from "./index";
4+
5+
/**
6+
* The deployed public overlay API was returning `data.stack` — internal file
7+
* paths and line numbers — to anyone who sent a bad overlay token. tRPC adds it
8+
* unless it believes it's in production, and a Worker's NODE_ENV isn't a reliable
9+
* signal, so it is stripped unconditionally.
10+
*/
11+
12+
test("stripErrorStack removes the stack from an error shape", () => {
13+
const shape = {
14+
message: "Invalid overlay token.",
15+
code: -32001,
16+
data: {
17+
code: "UNAUTHORIZED",
18+
httpStatus: 401,
19+
path: "state.getPublic",
20+
stack: "TRPCError: Invalid overlay token.\n at assertToken (index.js:28330:11)",
21+
},
22+
};
23+
const out = stripErrorStack(shape);
24+
expect("stack" in out.data).toBe(false);
25+
expect(JSON.stringify(out)).not.toContain("index.js");
26+
// Everything a client legitimately needs survives.
27+
expect(out.message).toBe("Invalid overlay token.");
28+
expect(out.code).toBe(-32001);
29+
expect(out.data.code).toBe("UNAUTHORIZED");
30+
expect(out.data.httpStatus).toBe(401);
31+
expect(out.data.path).toBe("state.getPublic");
32+
});
33+
34+
test("stripErrorStack leaves a shape that never had a stack alone", () => {
35+
const shape = { message: "nope", code: -32600, data: { code: "BAD_REQUEST", httpStatus: 400 } };
36+
expect(stripErrorStack(shape)).toEqual(shape);
37+
});
38+
39+
test("stripErrorStack preserves the zodError payload the client renders", () => {
40+
const shape = {
41+
message: "Input validation failed",
42+
code: -32600,
43+
data: {
44+
code: "BAD_REQUEST",
45+
httpStatus: 400,
46+
stack: "at zod",
47+
zodError: { fieldErrors: { goals: ["Required"] } },
48+
},
49+
};
50+
const out = stripErrorStack(shape);
51+
expect("stack" in out.data).toBe(false);
52+
expect(out.data.zodError).toEqual({ fieldErrors: { goals: ["Required"] } });
53+
});

packages/api/src/index.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,29 @@ import { initTRPC, TRPCError } from "@trpc/server";
22

33
import type { Context } from "./context";
44

5-
const t = initTRPC.context<Context>().create();
5+
/**
6+
* Drop `stack` from an error response.
7+
*
8+
* tRPC attaches a stack trace unless it believes it is running in production, and
9+
* a Worker's `NODE_ENV` is not a reliable signal — so the deployed PUBLIC overlay
10+
* API was returning internal file paths and line numbers to anyone who sent a bad
11+
* overlay token. Free reconnaissance with no upside: the full error still reaches
12+
* the Worker logs, which is where diagnosis belongs.
13+
*
14+
* Stripped unconditionally rather than gated on an env check, so this cannot
15+
* silently start leaking again because a build flag moved.
16+
*
17+
* Exported for the regression test — `errorFormatter` has no other seam.
18+
*/
19+
export function stripErrorStack<T extends { data: Record<string, unknown> }>(shape: T): T {
20+
if (!("stack" in shape.data)) return shape;
21+
const { stack: _stack, ...data } = shape.data;
22+
return { ...shape, data };
23+
}
24+
25+
const t = initTRPC.context<Context>().create({
26+
errorFormatter: ({ shape }) => stripErrorStack(shape),
27+
});
628

729
export const router = t.router;
830

0 commit comments

Comments
 (0)