Files
triggerdotdev--trigger.dev/apps/webapp/test/apiErrorBoundary.test.ts
T
Daniel Sutton 343fde250c fix(webapp): apply streaming content-type bypass on patchedEnd too
patchedWrite checks isStreamingContentType() before buffering; patchedEnd
only had the headersSent half. A 5xx response that sets a streaming
content-type and delivers its body via single-call res.end(chunk) would
get buffered, UTF-8 decoded, and run through the leak regex — wrong
shape for binary/SSE payloads. Mirrors the patchedWrite guard so the
same bypass applies regardless of write/end call shape.

Test pins the contract: a 5xx octet-stream body whose bytes contain
"P1001" passes through unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 11:11:55 +01:00

567 lines
21 KiB
TypeScript

import { beforeEach, describe, expect, test, vi } from "vitest";
// Intentional exception to the root CLAUDE.md "Never mock anything" rule.
//
// That rule targets unit-under-test and external-service mocking (Prisma,
// Redis, etc.) to avoid mock/prod drift — testcontainers is the prescribed
// alternative for those. Here we're mocking `logger.server` only as an
// *observability sink* so two tests can assert `logger.error` is/isn't
// called on the sanitize path. There is no production behavior the mock
// can disagree with; the alternatives (refactoring the middleware to a
// DI factory, or importing the real logger which pulls in Sentry + env
// validation) add indirection that neither this PR nor the codebase
// pattern wants. Mirrors the existing approach in 7+ other webapp test
// files that mock `logger.server` the same way.
vi.mock("../app/services/logger.server", () => ({
logger: {
info: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
warn: vi.fn(),
},
}));
import compression from "compression";
import express, { type Express } from "express";
import request from "supertest";
import { apiErrorBoundary } from "../app/services/apiErrorBoundary.server.js";
import { logger } from "../app/services/logger.server.js";
function buildApp(): Express {
const app = express();
app.use(apiErrorBoundary);
return app;
}
describe("apiErrorBoundary", () => {
beforeEach(() => {
vi.mocked(logger.error).mockClear();
});
test("sanitizes a 500 body that matches a leak rule", async () => {
const app = buildApp();
app.get("/api/v1/leaky", (_req, res) => {
res.status(500).json({
message:
"PrismaClientInitializationError: Can't reach database server at host:5432 (P1001)",
});
});
const response = await request(app).get("/api/v1/leaky");
expect(response.status).toBe(500);
expect(response.body).toEqual({ error: "Internal Server Error" });
});
// Remix's express adapter writes responses via
// `writeReadableStreamToWritable`, which calls `res.write(chunk)` for each
// body chunk and then `res.end()` with no arguments. Any sanitizer that
// only inspects `res.end`'s chunk is a no-op against Remix-emitted responses.
test("sanitizes a 500 body written via res.write then res.end() (Remix adapter pattern)", async () => {
const app = buildApp();
app.get("/api/v1/leaky-remix-style", (_req, res) => {
res.statusCode = 500;
res.setHeader("Content-Type", "application/json");
res.write(
Buffer.from(
JSON.stringify({
message:
"PrismaClientInitializationError: Can't reach database server at host:5432 (P1001)",
})
)
);
res.end();
});
const response = await request(app).get("/api/v1/leaky-remix-style");
expect(response.status).toBe(500);
expect(response.body).toEqual({ error: "Internal Server Error" });
});
// Remix's express adapter pipes a ReadableStream<Uint8Array> into the
// response, so chunks reach `res.write` as raw Uint8Array — not Node
// Buffers. Treating them with `String(chunk)` byte-mangles the body.
test("sanitizes a 500 body written as a Uint8Array (Remix stream pattern)", async () => {
const app = buildApp();
app.get("/api/v1/leaky-uint8array", (_req, res) => {
res.statusCode = 500;
res.setHeader("Content-Type", "application/json");
const payload = JSON.stringify({
message: "PrismaClientInitializationError: Can't reach database server (P1001)",
});
res.write(new Uint8Array(Buffer.from(payload, "utf8")));
res.end();
});
const response = await request(app).get("/api/v1/leaky-uint8array");
expect(response.status).toBe(500);
expect(response.body).toEqual({ error: "Internal Server Error" });
});
test("passes through a clean 5xx body written as a Uint8Array without mangling bytes", async () => {
const app = buildApp();
app.get("/api/v1/clean-uint8array", (_req, res) => {
res.statusCode = 500;
res.setHeader("Content-Type", "text/plain");
res.write(new Uint8Array(Buffer.from("Unexpected Server Error", "utf8")));
res.end();
});
const response = await request(app).get("/api/v1/clean-uint8array");
expect(response.status).toBe(500);
expect(response.text).toBe("Unexpected Server Error");
});
test("passes through a clean 500 body written via res.write then res.end()", async () => {
const app = buildApp();
app.get("/api/v1/clean-500-remix-style", (_req, res) => {
res.statusCode = 500;
res.setHeader("Content-Type", "application/json");
res.write(Buffer.from(JSON.stringify({ error: "Service unavailable for reasons" })));
res.end();
});
const response = await request(app).get("/api/v1/clean-500-remix-style");
expect(response.status).toBe(500);
expect(response.body).toEqual({ error: "Service unavailable for reasons" });
});
test("passes through a 500 body that does not match any rule", async () => {
const app = buildApp();
app.get("/api/v1/clean-500", (_req, res) => {
res.status(500).json({ error: "Service unavailable for reasons" });
});
const response = await request(app).get("/api/v1/clean-500");
expect(response.status).toBe(500);
expect(response.body).toEqual({ error: "Service unavailable for reasons" });
});
test("passes through 200 responses", async () => {
const app = buildApp();
app.get("/api/v1/ok", (_req, res) => {
res.status(200).json({ hello: "world" });
});
const response = await request(app).get("/api/v1/ok");
expect(response.status).toBe(200);
expect(response.body).toEqual({ hello: "world" });
});
// Only /api/* is monitored. /engine/*, /otel/*, /realtime/*, and UI/dashboard
// routes all pass through unchanged regardless of their body content.
test.each([
["/engine/v1/leaky"],
["/otel/v1/traces"],
["/realtime/v1/runs/run_123"],
["/dashboard/leaky"],
])("does NOT sanitize non-/api/ path: %s", async (path) => {
const app = buildApp();
app.get(path, (_req, res) => {
res.status(500).json({ message: "Boom: P1001 from a non-api route" });
});
const response = await request(app).get(path);
expect(response.status).toBe(500);
expect(response.body).toEqual({ message: "Boom: P1001 from a non-api route" });
});
// Kept as a separate assertion so the dashboard exclusion has its own
// grep-able test name; the rationale is different (UI ErrorBoundary).
test("does not sanitize dashboard/UI paths", async () => {
const app = buildApp();
app.get("/dashboard/leaky", (_req, res) => {
res.status(500).json({ message: "P1001 leak from a dashboard route" });
});
const response = await request(app).get("/dashboard/leaky");
expect(response.status).toBe(500);
expect(response.body).toEqual({ message: "P1001 leak from a dashboard route" });
});
test("passes through 4xx responses untouched even if body contains a leak pattern", async () => {
const app = buildApp();
app.get("/api/v1/bad-request", (_req, res) => {
res.status(400).json({ error: "Invalid params: P1001 included to ensure 4xx is exempt" });
});
const response = await request(app).get("/api/v1/bad-request");
expect(response.status).toBe(400);
expect(response.body).toEqual({
error: "Invalid params: P1001 included to ensure 4xx is exempt",
});
});
test("streams text/event-stream responses written as Uint8Array without buffering (Remix stream pattern)", async () => {
const app = buildApp();
app.get("/api/v1/stream", (_req, res) => {
res.statusCode = 200;
res.setHeader("Content-Type", "text/event-stream");
const enc = new TextEncoder();
res.write(enc.encode("data: chunk-1\n\n"));
res.write(enc.encode("data: chunk-2\n\n"));
res.end();
});
const response = await request(app).get("/api/v1/stream");
expect(response.status).toBe(200);
expect(response.text).toBe("data: chunk-1\n\ndata: chunk-2\n\n");
});
// Real Remix responses arrive as multiple Uint8Array chunks via
// writeReadableStreamToWritable. The single-chunk Uint8Array test above
// exercises one slice; this exercises the chunk-pushing/concat loop.
test("assembles multi-chunk Uint8Array writes into a single body without mangling", async () => {
const app = buildApp();
app.get("/api/v1/multichunk", (_req, res) => {
res.statusCode = 200;
res.setHeader("Content-Type", "application/json");
const enc = new TextEncoder();
res.write(enc.encode('{"part1":"hello"'));
res.write(enc.encode(',"part2":"world"}'));
res.end();
});
const response = await request(app).get("/api/v1/multichunk");
expect(response.status).toBe(200);
expect(response.body).toEqual({ part1: "hello", part2: "world" });
});
// The streaming-content-type bypass must fire on single-call res.end
// too, not only on res.write. If a 5xx response sets a streaming
// content-type and delivers its body via res.end(chunk) directly, the
// body must pass through untouched — the leak rules are JSON-text
// heuristics and have no business interpreting binary or SSE payloads.
test("does NOT sanitize a 5xx octet-stream body delivered via single-call res.end(chunk), even if bytes contain leak pattern", async () => {
const app = buildApp();
// Construct a binary payload whose bytes happen to spell "P1001"
// somewhere — proves the streaming bypass fires before the regex.
const binary = Buffer.concat([
Buffer.from([0x00, 0xff, 0xab]),
Buffer.from("P1001", "ascii"),
Buffer.from([0xcd, 0xef, 0x00]),
]);
app.get("/api/v1/octet-5xx", (_req, res) => {
res.statusCode = 500;
res.setHeader("Content-Type", "application/octet-stream");
res.end(binary);
});
const response = await request(app)
.get("/api/v1/octet-5xx")
.buffer(true)
.parse((res, cb) => {
const chunks: Buffer[] = [];
res.on("data", (c: Buffer) => chunks.push(c));
res.on("end", () => cb(null, Buffer.concat(chunks)));
});
expect(response.status).toBe(500);
expect(Buffer.compare(response.body as Buffer, binary)).toBe(0);
});
// patchedWrite applies the isStreamingContentType / MAX_BUFFER_BYTES
// bypass checks before buffering; patchedEnd applies the same
// streaming-content-type check. These tests pin the byte-integrity
// contract for the single-call res.end shape: octet-stream bodies
// and >64KB bodies both flow through unchanged.
test("preserves bytes for octet-stream body delivered via single-call res.end(chunk)", async () => {
const app = buildApp();
// Construct binary bytes that span the full 0-255 range to catch any
// accidental encoding interpretation.
const binary = Buffer.from(Array.from({ length: 512 }, (_, i) => i % 256));
app.get("/api/v1/octet-end", (_req, res) => {
res.statusCode = 200;
res.setHeader("Content-Type", "application/octet-stream");
res.end(binary);
});
const response = await request(app)
.get("/api/v1/octet-end")
.buffer(true)
.parse((res, cb) => {
const chunks: Buffer[] = [];
res.on("data", (c: Buffer) => chunks.push(c));
res.on("end", () => cb(null, Buffer.concat(chunks)));
});
expect(response.status).toBe(200);
expect(Buffer.compare(response.body as Buffer, binary)).toBe(0);
});
test("preserves bytes for >64KB body delivered via single-call res.end(chunk)", async () => {
const app = buildApp();
const payload = JSON.stringify({
filler: "x".repeat(80 * 1024),
marker: "END_OF_LARGE_BODY",
});
app.get("/api/v1/large-end", (_req, res) => {
res.statusCode = 200;
res.setHeader("Content-Type", "application/json");
res.end(Buffer.from(payload, "utf8"));
});
const response = await request(app).get("/api/v1/large-end");
expect(response.status).toBe(200);
expect(response.body.filler).toHaveLength(80 * 1024);
expect(response.body.marker).toBe("END_OF_LARGE_BODY");
});
// The actual production leak path: Remix's returnLastResortErrorResponse
// returns Content-Type: text/plain in non-production mode with the raw
// error message appended. Our middleware must detect the leak in text/plain
// bodies and rewrite the response to JSON before flushing.
test("sanitizes a text/plain 500 body matching a leak rule (Remix non-prod error shape)", async () => {
const app = buildApp();
app.get("/api/v1/text-plain-leak", (_req, res) => {
res.statusCode = 500;
res.setHeader("Content-Type", "text/plain");
res.write(
new TextEncoder().encode(
"Unexpected Server Error\n\nError: PrismaClientInitializationError: Can't reach database server (P1001)"
)
);
res.end();
});
const response = await request(app).get("/api/v1/text-plain-leak");
expect(response.status).toBe(500);
expect(response.headers["content-type"]).toMatch(/application\/json/);
expect(response.body).toEqual({ error: "Internal Server Error" });
});
// Responses that exceed MAX_BUFFER_BYTES (64KB) bypass the buffer to avoid
// holding large streams in memory. Bytes must still reach the client intact.
test("passes through a 5xx response larger than the buffer cap without mangling bytes", async () => {
const app = buildApp();
const filler = "x".repeat(80 * 1024);
app.get("/api/v1/large-5xx", (_req, res) => {
res.statusCode = 500;
res.setHeader("Content-Type", "application/json");
res.write(new TextEncoder().encode(JSON.stringify({ filler, marker: "END" })));
res.end();
});
const response = await request(app).get("/api/v1/large-5xx");
expect(response.status).toBe(500);
expect(response.body.filler).toHaveLength(80 * 1024);
expect(response.body.marker).toBe("END");
});
// If a route flushes headers before writing the body, the middleware
// cannot rewrite Content-Type / Content-Length later. It must skip
// sanitization gracefully — no crash, no byte-mangling — and let the
// original body through. (Per-route #3664 catches remain the primary
// defense; this is just about not making things worse.)
test("skips sanitization gracefully when response headers are already flushed", async () => {
const app = buildApp();
app.get("/api/v1/headers-flushed", (_req, res) => {
res.statusCode = 500;
res.setHeader("Content-Type", "text/plain");
res.flushHeaders();
res.write(new TextEncoder().encode("leaky body with P1001 written after headers flushed"));
res.end();
});
const response = await request(app).get("/api/v1/headers-flushed");
expect(response.status).toBe(500);
// Bytes must not be mangled into "85,110,..." style.
expect(response.text).not.toMatch(/^\d+(?:,\d+){5,}/);
// We cannot change Content-Type after flush, so the original text leaks
// through. That is an accepted limitation — per-route try/catch is the
// primary defense.
expect(response.text).toContain("leaky body with P1001");
});
// Same shape of bug as the original Uint8Array miss — if a chunk arrives
// as a typed array that is not specifically Uint8Array (e.g. Uint16Array,
// DataView), the `instanceof Uint8Array` check misses it and toBuffer
// falls through to String(chunk), byte-mangling the body. Guarding via
// ArrayBuffer.isView() catches all typed-array variants.
test("preserves bytes when chunk is a non-Uint8Array typed array (Uint16Array)", async () => {
const app = buildApp();
const text = "Hello, world!";
const utf16 = new Uint16Array(text.length);
for (let i = 0; i < text.length; i++) utf16[i] = text.charCodeAt(i);
const expectedBytes = Buffer.from(utf16.buffer, utf16.byteOffset, utf16.byteLength);
app.get("/api/v1/uint16", (_req, res) => {
res.statusCode = 200;
// Use application/json so the chunk hits the buffer path (not the
// streaming-content-type bypass).
res.setHeader("Content-Type", "application/json");
res.write(utf16);
res.end();
});
const response = await request(app)
.get("/api/v1/uint16")
.buffer(true)
.parse((res, cb) => {
const chunks: Buffer[] = [];
res.on("data", (c: Buffer) => chunks.push(c));
res.on("end", () => cb(null, Buffer.concat(chunks)));
});
expect(response.status).toBe(200);
expect(Buffer.compare(response.body as Buffer, expectedBytes)).toBe(0);
});
test("sanitizes a 503 body that matches a leak rule, preserving the original status", async () => {
const app = buildApp();
app.get("/api/v1/leaky-503", (_req, res) => {
res.status(503).json({
message: "PrismaClientInitializationError: Can't reach database server (P1001)",
});
});
const response = await request(app).get("/api/v1/leaky-503");
// The 503 is preserved so status-aware clients still know "service
// unavailable, retry" vs a generic 500. The body is sanitized.
expect(response.status).toBe(503);
expect(response.body).toEqual({ error: "Internal Server Error" });
});
test("sanitizes a leak on POST as well as GET (method-independent)", async () => {
const app = buildApp();
app.post("/api/v1/leaky-post", (_req, res) => {
res.status(500).json({ message: "Boom: Can't reach database server (P1001)" });
});
const response = await request(app).post("/api/v1/leaky-post").send({});
expect(response.status).toBe(500);
expect(response.body).toEqual({ error: "Internal Server Error" });
});
test("passes through an empty-body 5xx response without crashing", async () => {
const app = buildApp();
app.get("/api/v1/empty-500", (_req, res) => {
res.statusCode = 500;
res.end();
});
const response = await request(app).get("/api/v1/empty-500");
expect(response.status).toBe(500);
expect(response.text).toBe("");
});
test("matches leak rule case-insensitively (e.g. lowercase 'p1001' still sanitized)", async () => {
const app = buildApp();
app.get("/api/v1/leaky-lowercase", (_req, res) => {
res.status(500).json({ message: "boom: can't reach database server, code p1001" });
});
const response = await request(app).get("/api/v1/leaky-lowercase");
expect(response.status).toBe(500);
expect(response.body).toEqual({ error: "Internal Server Error" });
});
// server.ts mounts compression() before apiErrorBoundary, so compression's
// res.write/end wrappers are what our middleware's originalWrite/End point
// to. Verifies that the sanitized body still reaches the client correctly
// when compression is in the chain (supertest auto-decompresses).
test("sanitizes leak correctly when compression middleware is mounted in front", async () => {
const app = express();
app.use(compression({ threshold: 0 }));
app.use(apiErrorBoundary);
app.get("/api/v1/compressed-leak", (_req, res) => {
res.status(500).json({
message: "Boom: Can't reach database server (P1001)",
// Padding to ensure compression has something meaningful to do.
padding: "x".repeat(2048),
});
});
const response = await request(app)
.get("/api/v1/compressed-leak")
.set("Accept-Encoding", "gzip");
expect(response.status).toBe(500);
expect(response.body).toEqual({ error: "Internal Server Error" });
});
test("passes a non-leak compressed response through without mangling bytes", async () => {
const app = express();
app.use(compression({ threshold: 0 }));
app.use(apiErrorBoundary);
app.get("/api/v1/compressed-ok", (_req, res) => {
res.status(200).json({ hello: "world", padding: "y".repeat(2048) });
});
const response = await request(app)
.get("/api/v1/compressed-ok")
.set("Accept-Encoding", "gzip");
expect(response.status).toBe(200);
expect(response.body.hello).toBe("world");
expect(response.body.padding).toHaveLength(2048);
});
// HEAD requests run the loader but Node strips the body before sending.
// The middleware still buffers + matches against the (would-be) body, so
// its sanitize/log path still fires — we just want to confirm no crash
// and that the status is preserved.
test("does not crash on HEAD requests to leaky routes", async () => {
const app = buildApp();
app.get("/api/v1/leaky-head", (_req, res) => {
res.status(500).json({ message: "Boom: P1001" });
});
const response = await request(app).head("/api/v1/leaky-head");
expect(response.status).toBe(500);
expect(response.text ?? "").toBe("");
});
test("logs at error level when sanitization occurs", async () => {
const app = buildApp();
app.get("/api/v1/leaky-logged", (_req, res) => {
res.status(500).json({ message: "boom P1001 boom" });
});
await request(app).get("/api/v1/leaky-logged");
expect(logger.error).toHaveBeenCalledTimes(1);
const [message, context] = vi.mocked(logger.error).mock.calls[0];
expect(message).toMatch(/sanitized/i);
expect(context).toMatchObject({
rule: "prisma-p1001",
path: "/api/v1/leaky-logged",
status: 500,
});
});
test("does not log when no sanitization occurs", async () => {
const app = buildApp();
app.get("/api/v1/quiet", (_req, res) => {
res.status(200).json({ ok: true });
});
await request(app).get("/api/v1/quiet");
expect(logger.error).not.toHaveBeenCalled();
});
});