mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 05:42:19 +00:00
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:
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);
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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-
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user