Files
webmail/lib/error-reporting/scrub.ts
T

164 lines
8.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: everything after the first line,
* quoted text, addresses, URLs, file names, credentials, long random-looking
* strings and key=value pairs, and then every word that is not on a fixed
* list of common words. What stays is the shape of the message
* ("Cannot read properties of undefined (reading "…")").
*
* Documented at https://bulwarkmail.org/docs/deployment/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]');
// Last, and the rule the others only make readable: a word stays only when
// it is on the list of common words. Free text such as an unquoted subject
// or folder name ("to Acquisition Talks") becomes "…".
text = text.replace(WORD_OR_PLACEHOLDER, (token) => (
PLACEHOLDER.test(token) || COMMON_WORDS.has(token.toLowerCase()) ? token : REDACTED
));
text = text.replace(/\d{5,}/g, REDACTED).replace(/…(?:[\s,;]+…)+/g, REDACTED);
if (text.length > MAX_MESSAGE_LENGTH) text = `${text.slice(0, MAX_MESSAGE_LENGTH - 1)}…`;
return text;
}
const PLACEHOLDER = /^\[(?:email|url|token|file|id|ip|number|redacted)\]$/;
const WORD_OR_PLACEHOLDER = /\[(?:email|url|token|file|id|ip|number|redacted)\]|[\p{L}_$][\p{L}\p{N}_$'’.-]*/gu;
/**
* Words an error message may keep: English function words and the words
* JavaScript engines, browsers, Next.js and Bulwark's own errors are made of.
* Anything else could be content and is replaced.
*/
const COMMON_WORDS = new Set(`
a about above access after again against all already an and another any are argument arguments array as at attempt
attempted available bad be been before being body both but by call called calling can can't cannot canceled cancelled
change changed changes child chunk class client close closed code component connect connected connection constructor
context could create created data date decode default defined delete deleted denied did does doesn't done during each
element empty end entry error errors event exceed exceeded exist exists expected failed fetch field file files find
first for found from function get given got had has have header headers here hook hydration if in index initial
instead internal invalid is isn't it item items its json key keys length limit list load loaded loading lost
mailbox mailboxes match maximum may maybe message messages method missing more must network new no node none not
null number object of offline on once one only open operation or out over parse parsing permission please property
properties provided read reading ready received recursion redirect refused rejected render rendered rendering request
requested requests required resource response result retry returned save saved script send sent server session set
should size some stack state status still string such support supported sync syntax than that the then there these
this thread timed timeout to token too type unable undefined unexpected unknown update updated upload uploaded use
used user using valid value values was were when which while with within without worker write wrong
abort aborted account accounts attachment attachments auth authentication authorization basic bearer blob broke broken check health heartbeat metrics something telemetry version blocked browser cache calendar callback contact contacts cookie
cors credentials decrypt denied download draft email emails encrypt encrypted expired failure folder folders
identity identities import keyword keywords login logout mail move moved oauth outbox plugin
plugins push quota rate reply sender settings signature submission subscription template theme upload websocket
`.split(/\s+/).filter(Boolean));
/** 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';
}