Files
webmail/lib/error-reporting/scrub.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

127 lines
5.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* What an error report may carry, applied in the browser before sending and
* again on the server for everything a browser posts (lib/error-reporting).
*
* Mail content must never leave the browser in an error, not even to the
* admin's own error tracker: no message bodies, subjects, addresses, file
* names, tokens, cookies or URLs. Error messages are free text, so the rules
* remove whatever could be such content: quoted text, addresses, URLs, file
* names, credentials, long random-looking strings, key=value pairs, and
* everything after the first line. What stays is the shape of the message
* ("Cannot read properties of undefined (reading "…")").
*
* Documented at https://bulwarkmail.org/docs/admin/monitoring#what-is-removed
*/
export const MAX_MESSAGE_LENGTH = 200;
const REDACTED = '…';
// Quoted text: "…", '…', `…`, “…”, „…“, ‘…’, «…», ‹…›. A single quote only
// counts when it starts after a space or bracket, so "can't" stays a word.
const QUOTED: RegExp[] = [
/"[^"\n]*"/g,
/`[^`\n]*`/g,
/[“„][^”“\n]*[”“]/g,
/‘[^’\n]*’/g,
/«[^»\n]*»/g,
/‹[^›\n]*›/g,
/(^|[\s([{:,=])'[^'\n]*'(?=$|[\s)\]}.,:;!?])/g,
];
const EMAIL = /[^\s@<>"'`()[\]{},;:]+@[^\s@<>"'`()[\]{},;:]+\.[^\s@<>"'`()[\]{},;:]+/g;
const URL_LIKE = /\b[a-z][a-z0-9+.-]*:\/\/[^\s"'`<>()]*/gi;
const WWW = /\bwww\.[^\s"'`<>()]+/gi;
const AUTH = /\b(Bearer|Basic|Token)\s+[^\s,;]+/gi;
const JWT = /\beyJ[\w-]{5,}\.[\w-]{5,}\.[\w-]*/g;
const KEY_VALUE = /\b([A-Za-z_][\w.-]{0,40})=([^\s&;,]+)/g;
const FILE_NAME = /[^\s/\\"'`<>()]+\.(?:pdf|docx?|xlsx?|pptx?|odt|ods|odp|rtf|txt|csv|eml|msg|ics|vcf|zip|rar|7z|tar|gz|png|jpe?g|gif|webp|heic|svg|mp[34]|mov|wav|html?|xml|json|p7[msc]|asc|gpg|pgp|key|pem)\b/gi;
// Runs of letters and digits that look random: ids, hashes, keys, tokens.
const LONG_RUN = /[A-Za-z0-9+/_=-]{24,}/g;
const IPV4 = /\b\d{1,3}(?:\.\d{1,3}){3}\b/g;
const PHONE = /\+?\d[\d ()-]{7,}\d/g;
function looksRandom(run: string): boolean {
// Long identifiers in code are words glued together: keep them when they
// are only letters. Anything with digits or symbols mixed in goes.
return /\d/.test(run) || /[+/=_-]/.test(run) || run.length > 40;
}
/** Free text from an error, made safe to send. */
export function scrubText(input: unknown): string {
if (typeof input !== 'string' || input === '') return '';
// Only the first line: server answers, stack traces and multi-line content
// that ended up in a message all come after it.
let text = input.split(/\r?\n/, 1)[0];
// "Request failed: 400 - <server answer>": the answer may echo a subject.
text = text.replace(/^(.*?\b\d{3})\s+-\s.*$/, '$1');
text = text.replace(AUTH, '$1 [token]');
text = text.replace(JWT, '[token]');
text = text.replace(URL_LIKE, '[url]');
text = text.replace(WWW, '[url]');
text = text.replace(EMAIL, '[email]');
for (const quoted of QUOTED) {
text = text.replace(quoted, (match, lead?: string) => {
const prefix = typeof lead === 'string' ? lead : '';
const quote = match.slice(prefix.length, prefix.length + 1);
const close = match.slice(-1);
return `${prefix}${quote}${REDACTED}${close}`;
});
}
text = text.replace(FILE_NAME, '[file]');
text = text.replace(KEY_VALUE, '$1=[redacted]');
text = text.replace(LONG_RUN, (run) => (looksRandom(run) ? '[id]' : run));
text = text.replace(IPV4, '[ip]');
text = text.replace(PHONE, '[number]');
if (text.length > MAX_MESSAGE_LENGTH) text = `${text.slice(0, MAX_MESSAGE_LENGTH - 1)}…`;
return text;
}
/** An error name: a code identifier, or "Error". */
export function scrubName(input: unknown): string {
return typeof input === 'string' && /^[A-Za-z_$][\w$]{0,63}$/.test(input) ? input : 'Error';
}
/** A function name from a stack frame: identifiers and the usual decorations only. */
export function scrubFunctionName(input: unknown): string | undefined {
if (typeof input !== 'string' || input === '') return undefined;
const trimmed = input.trim().slice(0, 120);
return /^[\w$.<>[\] /#-]+$/.test(trimmed) && !/@/.test(trimmed) ? trimmed : '?';
}
/**
* The path of a stack frame's file, without origin, base path, query or
* fragment: `/_next/static/chunks/x.js` for the browser bundle,
* `/.next/server/...` for the server's, `node:...` for Node's own modules,
* `node_modules/...` for dependencies; `[other]` for anything else
* (extensions, plugin code, evaluated code).
*/
export function scrubFramePath(input: unknown): string {
if (typeof input !== 'string' || input === '') return '[other]';
const path = input.split(/[?#]/, 1)[0].replace(/\\/g, '/');
for (const marker of ['/_next/static/', '/.next/server/', '/.next/static/']) {
const at = path.indexOf(marker);
if (at >= 0) {
const rest = path.slice(at);
return /^[\w./()[\]@+~-]+$/.test(rest) ? rest.replace('/.next/static/', '/_next/static/') : '[other]';
}
}
if (/^node:[\w/.-]+$/.test(path)) return path;
const modules = path.lastIndexOf('/node_modules/');
if (modules >= 0) {
const rest = path.slice(modules + 1);
return /^[\w./@+~-]+$/.test(rest) ? rest : '[other]';
}
return '[other]';
}
/** A JMAP method name ("Email/get") or a fixed placeholder. */
export function scrubMethodName(input: unknown): string {
return typeof input === 'string' && /^[A-Z][A-Za-z]{0,40}\/[a-z][A-Za-z]{0,40}$/.test(input) ? input : 'Unknown/method';
}
/** A JMAP error type ("serverFail", "overQuota") or a fixed placeholder. */
export function scrubJmapErrorType(input: unknown): string {
return typeof input === 'string' && /^[a-z][A-Za-z]{1,40}$/.test(input) ? input : 'unknown';
}