Skip to content

Commit 732f8d4

Browse files
d-csclaude
andcommitted
fix(webapp): sanitize Prisma P1001 leaks in /api/* responses
Adds an Express middleware that intercepts /api/* responses and, when the status is 5xx and the body matches a known Prisma P1001 ("Can't reach database server") leak, rewrites the body to a generic {"error":"Internal Server Error"} before it reaches the client. The status code is preserved. Sits outside Remix so it catches leaks the per-route try/catch from #3664 didn't cover. Deliberately surgical: one rule (P1001 only), one namespace (/api/* only). /engine/, /otel/, /realtime/, and UI routes are intentionally excluded. Expandable later by appending to LEAK_RULES or MONITORED_PREFIXES. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent e825409 commit 732f8d4

5 files changed

Lines changed: 637 additions & 0 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: improvement
4+
---
5+
6+
`/api/*` responses no longer leak Prisma "Can't reach database server" (P1001) errors when the database is unreachable — affected responses are rewritten to a generic Internal Server Error before reaching the client.

apps/webapp/app/entry.server.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -289,6 +289,7 @@ process.on("uncaughtException", (error, origin) => {
289289
singleton("RunEngineEventBusHandlers", registerRunEngineEventBusHandlers);
290290
singleton("SetupBatchQueueCallbacks", setupBatchQueueCallbacks);
291291

292+
export { apiErrorBoundary } from "./services/apiErrorBoundary.server";
292293
export { apiRateLimiter } from "./services/apiRateLimit.server";
293294
export { engineRateLimiter } from "./services/engineRateLimit.server";
294295
export { runWithHttpContext } from "./services/httpAsyncStorage.server";
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
import type { RequestHandler } from "express";
2+
import { logger } from "./logger.server";
3+
4+
// Last line of defense for leaked error messages in `/api/*` responses.
5+
//
6+
// PR #3664 added a per-route try/catch to every `api.v1.*` loader/action so an
7+
// unhandled throw can no longer reach Remix's default error path and surface
8+
// `error.message` to the client. This middleware sits one layer further out:
9+
// when an `/api/*` response ends with status 5xx and the assembled body matches
10+
// a known leak rule, the body is rewritten to a generic
11+
// `{"error":"Internal Server Error"}` before it reaches the client. The
12+
// original status code is preserved — a leaky 503 stays 503 — so status-aware
13+
// clients keep any nuance the route deliberately set.
14+
//
15+
// Remix's express adapter writes the response body via `res.write(chunk)` (not
16+
// `res.end(chunk)`), so this middleware must buffer writes and evaluate the
17+
// assembled body at `end` time. SSE responses bypass buffering by content-type
18+
// inspection; oversized responses bypass via a hard cap so streaming code
19+
// paths that exceed the cap are not held in memory.
20+
//
21+
// Conservative initial rule set: only the patterns we have observed leaking
22+
// in production are filtered. Mirrors `FINGERPRINT_RULES` in
23+
// `apps/webapp/sentry.server.ts`. Add a rule here when a new leak is spotted.
24+
const LEAK_RULES: ReadonlyArray<{ name: string; pattern: RegExp }> = [
25+
{
26+
// Prisma "Can't reach database server" — surfaces connection strings and
27+
// host info when Postgres is unreachable mid-query (the failure mode that
28+
// motivated #3664 and the Sentry P1001 fingerprint rule).
29+
name: "prisma-p1001",
30+
pattern: /\bP1001\b|Can't reach database server/i,
31+
},
32+
];
33+
34+
const SANITIZED_BODY = JSON.stringify({ error: "Internal Server Error" });
35+
36+
// Path prefixes the middleware monitors. Scoped to `/api/*` only — the SDK
37+
// surface where leaks have actually been reported. `/engine/*`, `/otel/*`,
38+
// `/realtime/*`, and UI/dashboard routes are intentionally out of scope:
39+
// they have different traffic profiles, leak shapes, or already self-handle
40+
// errors. Expand this list reactively when a leak is observed on another
41+
// namespace.
42+
const MONITORED_PREFIXES = ["/api/"] as const;
43+
44+
// Error responses are tiny. A buffered response that grows past this cap is
45+
// not a 5xx error body — bail out of buffering so streaming responses do not
46+
// pile up in memory.
47+
const MAX_BUFFER_BYTES = 64 * 1024;
48+
49+
export const apiErrorBoundary: RequestHandler = (req, res, next) => {
50+
if (!MONITORED_PREFIXES.some((prefix) => req.path.startsWith(prefix))) {
51+
return next();
52+
}
53+
54+
const originalWrite = res.write.bind(res);
55+
const originalEnd = res.end.bind(res);
56+
57+
let chunks: Buffer[] = [];
58+
let bufferedBytes = 0;
59+
let bypass = false;
60+
61+
const flushAndBypass = () => {
62+
if (bypass) return;
63+
bypass = true;
64+
if (chunks.length === 0) return;
65+
const buffered = Buffer.concat(chunks);
66+
chunks = [];
67+
bufferedBytes = 0;
68+
(originalWrite as (c: Buffer) => boolean)(buffered);
69+
};
70+
71+
const isStreamingContentType = (): boolean => {
72+
const ct = String(res.getHeader("content-type") ?? "");
73+
if (!ct) return false;
74+
return ct.includes("text/event-stream") || ct.includes("application/octet-stream");
75+
};
76+
77+
const toBuffer = (chunk: unknown): Buffer => {
78+
if (Buffer.isBuffer(chunk)) return chunk;
79+
// Covers Uint8Array (Remix's stream output), Uint16Array, Int32Array,
80+
// DataView, etc. — anything backed by an ArrayBuffer. Without this,
81+
// `String(chunk)` byte-mangles typed arrays into "85,110,..." strings.
82+
if (ArrayBuffer.isView(chunk)) {
83+
return Buffer.from(chunk.buffer, chunk.byteOffset, chunk.byteLength);
84+
}
85+
if (typeof chunk === "string") return Buffer.from(chunk);
86+
return Buffer.from(String(chunk));
87+
};
88+
89+
const patchedWrite = (chunk: unknown, ...rest: unknown[]): boolean => {
90+
// If headers have already been flushed, we cannot rewrite Content-Type
91+
// or status later — sanitization is impossible. Flush + bypass and let
92+
// bytes flow through unmodified.
93+
if (!bypass && (res.headersSent || isStreamingContentType())) flushAndBypass();
94+
if (bypass) {
95+
return (originalWrite as (c: unknown, ...r: unknown[]) => boolean)(chunk, ...rest);
96+
}
97+
if (chunk != null) {
98+
const buf = toBuffer(chunk);
99+
chunks.push(buf);
100+
bufferedBytes += buf.length;
101+
if (bufferedBytes > MAX_BUFFER_BYTES) flushAndBypass();
102+
}
103+
return true;
104+
};
105+
106+
const patchedEnd = (chunk?: unknown, ...rest: unknown[]) => {
107+
// Same guard as patchedWrite: if headers were flushed (e.g. via an
108+
// explicit `res.flushHeaders()` before end-with-body), we cannot rewrite
109+
// the response — flush buffered chunks unchanged and skip sanitization.
110+
if (!bypass && res.headersSent) flushAndBypass();
111+
if (bypass) {
112+
return (originalEnd as (c?: unknown, ...r: unknown[]) => typeof res)(chunk, ...rest);
113+
}
114+
if (chunk != null) chunks.push(toBuffer(chunk));
115+
const body = Buffer.concat(chunks);
116+
bypass = true;
117+
118+
if (res.statusCode >= 500 && body.length > 0) {
119+
const text = body.toString("utf8");
120+
const matched = LEAK_RULES.find((rule) => rule.pattern.test(text));
121+
if (matched) {
122+
logger.error("apiErrorBoundary sanitized leaked error response", {
123+
rule: matched.name,
124+
path: req.path,
125+
method: req.method,
126+
status: res.statusCode,
127+
});
128+
res.setHeader("Content-Type", "application/json");
129+
res.removeHeader("Content-Length");
130+
return (originalEnd as (c?: unknown) => typeof res)(SANITIZED_BODY);
131+
}
132+
}
133+
134+
if (body.length > 0) (originalWrite as (c: Buffer) => boolean)(body);
135+
return (originalEnd as () => typeof res)();
136+
};
137+
138+
res.write = patchedWrite as unknown as typeof res.write;
139+
res.end = patchedEnd as unknown as typeof res.end;
140+
141+
next();
142+
};

apps/webapp/server.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,7 @@ if (ENABLE_CLUSTER && cluster.isPrimary) {
119119
if (process.env.HTTP_SERVER_DISABLED !== "true") {
120120
const socketIo: { io: IoServer } | undefined = build.entry.module.socketIo;
121121
const wss: WebSocketServer | undefined = build.entry.module.wss;
122+
const apiErrorBoundary: express.RequestHandler = build.entry.module.apiErrorBoundary;
122123
const apiRateLimiter: RateLimitMiddleware = build.entry.module.apiRateLimiter;
123124
const engineRateLimiter: RateLimitMiddleware = build.entry.module.engineRateLimiter;
124125
const runWithHttpContext: RunWithHttpContextFunction = build.entry.module.runWithHttpContext;
@@ -168,6 +169,11 @@ if (ENABLE_CLUSTER && cluster.isPrimary) {
168169
});
169170
}
170171

172+
// Universal error boundary for /api/* responses. Sits outside Remix so
173+
// it catches leaks regardless of whether the per-route try/catch from
174+
// PR #3664 covered them.
175+
app.use(apiErrorBoundary);
176+
171177
app.use(apiRateLimiter);
172178
app.use(engineRateLimiter);
173179

0 commit comments

Comments
 (0)