Skip to content

Commit c35c0ad

Browse files
Merge pull request #93 from MrDemonWolf/claude/trpc-error-formatter
fix(api): stop returning stack traces to clients
2 parents 63a263c + 5fa826b commit c35c0ad

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)