mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-03 08:15:38 +00:00
Admins had logs and nothing else: client errors only reached the browser console. An admin can now set a Sentry-protocol address (DSN) for Bugsink, GlitchTip or Sentry under System > Monitoring, or with `ERROR_REPORTING_DSN`, `ERROR_REPORTING_DSN_FILE`, `ERROR_REPORTING_SAMPLE_RATE` and `ERROR_REPORTING_ENVIRONMENT`. Bulwark Lite reads the same from `errorReporting` in policy.json. Everything stays off while no address is set. - browsers report errors from the error boundaries, window.onerror, unhandled rejections and failed JMAP requests (method and error type only), with route and JMAP method breadcrumbs and the chunks' debug IDs - the server reports what Next hands to onRequestError and what routes log with logger.error - every report is scrubbed first: quoted text, addresses, URLs, file names, tokens and the rest after the first line are removed, and the server scrubs what browsers post to /api/errors again - reports are deduplicated and rate-limited per session and process - errors are counted per day for "errors today" on the admin page - startup messages and audit entries go through the logger, so `LOG_FORMAT=json` covers them
71 lines
2.9 KiB
TypeScript
71 lines
2.9 KiB
TypeScript
import { NextRequest, NextResponse } from 'next/server';
|
|
import { getClientIP } from '@/lib/admin/session';
|
|
import { getStore } from '@/lib/shared-store';
|
|
import { recordError } from '@/lib/error-reporting/aggregate';
|
|
import { appVersion, isErrorCode } from '@/lib/error-reporting/envelope';
|
|
import { scrubName } from '@/lib/error-reporting/scrub';
|
|
import { shouldCollectErrors } from '@/lib/error-reporting/settings';
|
|
import { sanitizeFrames } from '@/lib/error-reporting/stack';
|
|
|
|
export const dynamic = 'force-dynamic';
|
|
|
|
/**
|
|
* POST /api/errors: a browser of this instance reports an error it caught
|
|
* (lib/error-reporting/client.ts), for the per-day error counts. Only while
|
|
* collection is on (a DSN, the metrics endpoint or error-count consent);
|
|
* otherwise 204 and nothing is kept.
|
|
*
|
|
* Same-origin only (the proxy's origin gate), at most 16 KB, 30 a minute per
|
|
* address. Nothing a browser sends is trusted: every field is scrubbed again
|
|
* and the fingerprint is computed here. The DSN gets the browser's own
|
|
* report; this route only counts.
|
|
*/
|
|
|
|
const MAX_BODY_BYTES = 16 * 1024;
|
|
const PER_MINUTE = 30;
|
|
|
|
const NO_STORE = { 'Cache-Control': 'no-store' };
|
|
|
|
export async function POST(request: NextRequest) {
|
|
if (!(await shouldCollectErrors())) return new NextResponse(null, { status: 204, headers: NO_STORE });
|
|
|
|
const length = Number(request.headers.get('content-length') ?? '0');
|
|
if (length > MAX_BODY_BYTES) return NextResponse.json({ error: 'too large' }, { status: 413, headers: NO_STORE });
|
|
|
|
const limit = await getStore().counters.reserve([
|
|
{ key: `error-reports:${getClientIP(request)}`, max: PER_MINUTE, windowMs: 60_000 },
|
|
]).catch(() => ({ ok: true }));
|
|
if (!limit.ok) return NextResponse.json({ error: 'rate limited' }, { status: 429, headers: NO_STORE });
|
|
|
|
let text: string;
|
|
try {
|
|
text = await request.text();
|
|
} catch {
|
|
return NextResponse.json({ error: 'bad request' }, { status: 400, headers: NO_STORE });
|
|
}
|
|
if (text.length > MAX_BODY_BYTES) return NextResponse.json({ error: 'too large' }, { status: 413, headers: NO_STORE });
|
|
|
|
let body: Record<string, unknown>;
|
|
try {
|
|
const parsed = JSON.parse(text) as unknown;
|
|
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) throw new Error('not an object');
|
|
body = parsed as Record<string, unknown>;
|
|
} catch {
|
|
return NextResponse.json({ error: 'bad request' }, { status: 400, headers: NO_STORE });
|
|
}
|
|
|
|
const code = isErrorCode(body.code) ? body.code : undefined;
|
|
recordError(
|
|
{
|
|
name: scrubName(body.name),
|
|
frames: sanitizeFrames(body.frames),
|
|
...(code ? { code } : {}),
|
|
},
|
|
'browser',
|
|
// The version the tab runs (a tab left open across an update runs the
|
|
// old one, and its frames point into that); the server's when absent.
|
|
typeof body.version === 'string' && /^\d+\.\d+\.\d+(?:[-+][\w.-]{1,40})?$/.test(body.version) ? body.version : appVersion(),
|
|
);
|
|
return new NextResponse(null, { status: 204, headers: NO_STORE });
|
|
}
|