Files
webmail/lib/error-reporting/limiter.ts
T
Linus Rath cba1109444 feat: report browser and server errors to an error tracker the admin sets
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
2026-11-01 02:50:19 +01:00

82 lines
2.8 KiB
TypeScript

/**
* Keeps error reporting from flooding anyone: each fingerprint once per
* window, at most `perMinute` reports in any minute and `perSession` in all.
* The browser keeps its state in sessionStorage, so a page that crashes on
* every load doesn't start over each time.
*/
export interface LimiterOptions {
perMinute: number;
perSession: number;
/** How long a fingerprint stays "already sent"; Infinity for the whole session. */
dedupWindowMs: number;
/** Persist across page loads (sessionStorage); absent on the server. */
storage?: Pick<Storage, 'getItem' | 'setItem'> | null;
storageKey?: string;
now?: () => number;
}
interface State {
total: number;
sent: Record<string, number>;
}
const MAX_REMEMBERED = 500;
export class ReportLimiter {
private readonly options: Required<Omit<LimiterOptions, 'storage'>> & { storage: LimiterOptions['storage'] };
private recent: number[] = [];
private state: State = { total: 0, sent: {} };
constructor(options: LimiterOptions) {
this.options = { storageKey: 'bulwark.error-reporting', now: () => Date.now(), storage: null, ...options };
this.load();
}
private load(): void {
const { storage, storageKey } = this.options;
if (!storage) return;
try {
const raw = storage.getItem(storageKey);
if (!raw) return;
const parsed = JSON.parse(raw) as Partial<State>;
if (typeof parsed.total === 'number' && parsed.sent && typeof parsed.sent === 'object') {
this.state = { total: parsed.total, sent: parsed.sent as Record<string, number> };
}
} catch {
// Unreadable or blocked storage: start fresh, in memory.
}
}
private save(): void {
const { storage, storageKey } = this.options;
if (!storage) return;
try {
storage.setItem(storageKey, JSON.stringify(this.state));
} catch {
// Quota or privacy mode: the in-memory state still applies.
}
}
/** Whether a report with this fingerprint may go out now; counts it when it may. */
allow(fingerprint: string): boolean {
const now = this.options.now();
const last = this.state.sent[fingerprint];
if (last !== undefined && now - last < this.options.dedupWindowMs) return false;
if (this.state.total >= this.options.perSession) return false;
this.recent = this.recent.filter((at) => now - at < 60_000);
if (this.recent.length >= this.options.perMinute) return false;
this.recent.push(now);
this.state.total += 1;
this.state.sent[fingerprint] = now;
const keys = Object.keys(this.state.sent);
if (keys.length > MAX_REMEMBERED) {
for (const key of keys.sort((a, b) => this.state.sent[a] - this.state.sent[b]).slice(0, keys.length - MAX_REMEMBERED)) {
delete this.state.sent[key];
}
}
this.save();
return true;
}
}