|
| 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 | +}; |
0 commit comments