diff --git a/src/app/api/monitoring/glitchtip/__tests__/route.test.ts b/src/app/api/monitoring/glitchtip/__tests__/route.test.ts index ca5b1efbc..c1bf70f32 100644 --- a/src/app/api/monitoring/glitchtip/__tests__/route.test.ts +++ b/src/app/api/monitoring/glitchtip/__tests__/route.test.ts @@ -6,6 +6,7 @@ vi.mock("@/lib/db", () => ({ prisma: {} })); vi.mock("@/lib/api-handler", () => ({ apiHandler: Promise>(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>); 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); }); diff --git a/src/app/api/monitoring/glitchtip/route.ts b/src/app/api/monitoring/glitchtip/route.ts index 0d444cc59..00715f9b8 100644 --- a/src/app/api/monitoring/glitchtip/route.ts +++ b/src/app/api/monitoring/glitchtip/route.ts @@ -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(); diff --git a/src/lib/logging/__tests__/redact.test.ts b/src/lib/logging/__tests__/redact.test.ts index 2d9d1511c..5912b81bf 100644 --- a/src/lib/logging/__tests__/redact.test.ts +++ b/src/lib/logging/__tests__/redact.test.ts @@ -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", + ); + }); +}); diff --git a/src/lib/logging/redact.ts b/src/lib/logging/redact.ts index 84f9ce9df..ee83cd33d 100644 --- a/src/lib/logging/redact.ts +++ b/src/lib/logging/redact.ts @@ -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- diff --git a/src/lib/monitoring/glitchtip.ts b/src/lib/monitoring/glitchtip.ts index 779d45daa..e9a16eb50 100644 --- a/src/lib/monitoring/glitchtip.ts +++ b/src/lib/monitoring/glitchtip.ts @@ -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);