fix(monitoring): sign the GlitchTip relay in and strip Prisma call arguments

The browser error relay forwarded anything posted to it into the operator's
GlitchTip without a session, limited only per address, so anyone could
fill the incident list. Separately, a Prisma client error prints the
failed call into its message and stack, with the arguments object (a
reading's value, a note's text) when no source map is at hand, and the
server sent that to GlitchTip and the logs after a credential scrub that
does not look for it.

The relay now needs a signed-in session and is limited per account; errors
on pages before sign-in go unreported. The central redaction cuts a Prisma
call's printed arguments, keeping the call, a reason line that names a
field, and the stack frames, and the GlitchTip sender applies the
redaction to every event at the egress boundary, whichever caller built it.
This commit is contained in:
Marc-André Bombeck committed 2026-10-25 13:52:04 +01:00
1 parent f2a9b0fc0e
commit a20897b305
5 files changed
+121 -19

No files matched your search

@@ -6,6 +6,7 @@ vi.mock("@/lib/db", () => ({ prisma: {} }));
vi.mock("@/lib/api-handler", () => ({
apiHandler: <T extends (...args: unknown[]) => Promise<Response>>(h: T): T =>
h,
requireAuth: vi.fn(),
}));
vi.mock("@/lib/rate-limit", () => ({
@@ -29,6 +30,7 @@ import { POST } from "../route";
import { checkRateLimit } from "@/lib/rate-limit";
import { getGlitchtipSettings } from "@/lib/monitoring-settings";
import { sendGlitchtipEvent } from "@/lib/monitoring/glitchtip";
import { requireAuth } from "@/lib/api-handler";
function jsonReq(body: unknown, contentType = "application/json"): NextRequest {
return new NextRequest("http://localhost/api/monitoring/glitchtip", {
@@ -40,6 +42,9 @@ function jsonReq(body: unknown, contentType = "application/json"): NextRequest {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(requireAuth).mockResolvedValue({
user: { id: "user-1" },
} as Awaited<ReturnType<typeof requireAuth>>);
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
remaining: 19,
@@ -58,6 +63,20 @@ beforeEach(() => {
});
describe("POST /api/monitoring/glitchtip", () => {
it("relays nothing for a caller without a session", async () => {
vi.mocked(requireAuth).mockRejectedValue(new Error("Unauthorized"));
await expect(POST(jsonReq({ message: "injected" }))).rejects.toThrow();
expect(sendGlitchtipEvent).not.toHaveBeenCalled();
expect(checkRateLimit).not.toHaveBeenCalled();
});
it("limits the relay per account", async () => {
await POST(jsonReq({ message: "boom" }));
expect(vi.mocked(checkRateLimit).mock.calls[0]![0]).toBe(
"glitchtip:user:user-1",
);
});
it("returns 429 when rate limit is exceeded", async () => {
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: false,
@@ -169,20 +188,9 @@ describe("POST /api/monitoring/glitchtip", () => {
expect(res.status).toBe(502);
});
it("rate-limits per IP — uses x-forwarded-for as the bucket key", async () => {
const r = new NextRequest("http://localhost/api/monitoring/glitchtip", {
method: "POST",
headers: {
"content-type": "application/json",
"x-forwarded-for": "10.0.0.42",
},
body: JSON.stringify({ message: "boom" }),
});
await POST(r);
it("keeps the documented 20 per minute window", async () => {
await POST(jsonReq({ message: "boom" }));
const callArgs = vi.mocked(checkRateLimit).mock.calls[0];
// First arg is the bucket key — must include the resolved client IP.
expect(callArgs?.[0]).toContain("10.0.0.42");
// 20 requests / 60 000 ms — guard the documented sliding window.
expect(callArgs?.[1]).toBe(20);
expect(callArgs?.[2]).toBe(60_000);
});
+9 -4
View File
@@ -4,13 +4,12 @@ import {
apiError,
apiSuccess,
apiValidationError,
getClientIp,
sanitiseZodIssues,
} from "@/lib/api-response";
import { checkRateLimit } from "@/lib/rate-limit";
import { getGlitchtipSettings } from "@/lib/monitoring-settings";
import { sendGlitchtipEvent } from "@/lib/monitoring/glitchtip";
import { apiHandler } from "@/lib/api-handler";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate, getEvent } from "@/lib/logging/context";
const clientErrorSchema = z
@@ -24,11 +23,17 @@ const clientErrorSchema = z
})
.strict();
/**
* The browser's error reporter relays here into the operator's GlitchTip.
* It needs a signed-in session: the relay used to be anonymous, so anyone
* could write events into the incident list, limited only per address. A
* page that fails before sign-in goes unreported, which is the price.
*/
export const POST = apiHandler(async (request: NextRequest) => {
annotate({ action: { name: "monitoring.glitchtip.report" } });
const ip = getClientIp(request);
const rl = await checkRateLimit(`glitchtip:${ip}`, 20, 60 * 1000);
const { user } = await requireAuth();
const rl = await checkRateLimit(`glitchtip:user:${user.id}`, 20, 60 * 1000);
if (!rl.allowed) return apiError("Rate limit exceeded", 429);
const settings = await getGlitchtipSettings();
+52
View File
@@ -325,3 +325,55 @@ describe("notification quick-answer token in the path", () => {
);
});
});
/**
* Prisma prints a failed call, arguments and all, into its error message and
* stack. Those went to the logs and to the operator's GlitchTip with a
* measurement's value or a note's text inside.
*/
describe("redactSecrets — a Prisma call's printed arguments", () => {
const message = [
"",
"Invalid `prisma.measurement.create()` invocation:",
"",
"{",
" data: {",
' type: "BLOOD_PRESSURE_SYS",',
" value: 187,",
' note: "felt dizzy after the argument",',
"+ userId: String",
" }",
"}",
"",
"Argument `userId` is missing.",
].join("\n");
it("keeps the call and the reason and drops the arguments", () => {
const out = redactSecrets(message);
expect(out).toContain("Invalid `prisma.measurement.create()` invocation");
expect(out).toContain("[query arguments removed]");
expect(out).toContain("Argument `userId` is missing.");
expect(out).not.toContain("187");
expect(out).not.toContain("dizzy");
});
it("keeps the stack frames after the arguments", () => {
const stack = `PrismaClientValidationError: ${message}\n at Mn (/app/.next/server/chunks/1.js:1:2)\n at async POST (/app/.next/server/app/api/measurements/route.js:3:4)`;
const out = redactSecrets(stack);
expect(out).not.toContain("dizzy");
expect(out).toContain(" at async POST");
});
it("drops a reason line that is not one of the field-naming forms", () => {
const out = redactSecrets(
'Invalid `prisma.note.update()` invocation in\n/app/x.js:1:1\n\n→ 1 prisma.note.update({ data: { text: "private" } })\n\nprivate text echoed back',
);
expect(out).not.toContain("private");
});
it("leaves every other message alone", () => {
expect(redactSecrets("plain failure at step 3")).toBe(
"plain failure at step 3",
);
});
});
+31 -1
View File
@@ -91,9 +91,39 @@ function redactPathSegments(input: string): string {
return out;
}
/**
* A Prisma client error describes its failed call by printing the call: the
* code frame around it, or with no source map the arguments object itself,
* health values and all. Everything between the "Invalid `prisma.x.y()`
* invocation" line and the stack frames goes; the reason line stays when it
* is one of the forms that name a field rather than a value.
*/
const PRISMA_INVOCATION = /Invalid `[^`\n]*` invocation[^\n]*/;
const PRISMA_REASON =
/^(?:Argument `|Unknown argument `|Unknown field `|Invalid value for argument `|Unique constraint failed|Foreign key constraint|Null constraint violation|An operation failed because|Transaction )/;
export function redactPrismaInvocation(input: string): string {
const head = PRISMA_INVOCATION.exec(input);
if (!head) return input;
const start = head.index + head[0].length;
const framesAt = input.indexOf("\n at ", start);
const detail = input.slice(start, framesAt === -1 ? undefined : framesAt);
const reason = detail
.split("\n")
.map((line) => line.trim())
.filter(Boolean)
.at(-1);
return (
input.slice(0, start) +
"\n[query arguments removed]" +
(reason && PRISMA_REASON.test(reason) ? `\n${reason}` : "") +
(framesAt === -1 ? "" : input.slice(framesAt))
);
}
export function redactSecrets(input: string): string {
return (
redactPathSegments(input)
redactPathSegments(redactPrismaInvocation(input))
.replace(/Bearer\s+\S+/gi, "Bearer [REDACTED]")
.replace(/bot\d+:[A-Za-z0-9_-]+/g, "bot[REDACTED]")
// `sk-` is a common English-prefix substring (task-force, risk-
+8 -1
View File
@@ -1,3 +1,4 @@
import { redactOptional, redactSecrets } from "@/lib/logging/redact";
import { safeFetch } from "@/lib/safe-fetch";
interface ParsedGlitchtipDsn {
@@ -245,7 +246,13 @@ export async function sendGlitchtipEvent(params: {
}
const eventId = createEventId();
const payload = buildEventPayload(eventId, params.input);
// The egress boundary: whatever a caller passed, the message and stack are
// scrubbed of credentials and of a Prisma call's printed arguments here.
const payload = buildEventPayload(eventId, {
...params.input,
message: redactSecrets(params.input.message),
stack: redactOptional(params.input.stack),
});
const envelopeBody = buildEnvelopeBody(config, eventId, payload);
const envelopeResult = await tryEnvelope(config, envelopeBody);