import { Router, Request, Response } from 'express'; import { clientErrorLimiter } from '../rateLimit'; const router = Router(); // The three error boundaries in the frontend. An unrecognised context means the // client and the server disagree about something, which is worth surfacing // rather than logging under a guessed label — the same reasoning as // parseItemFilters refusing a malformed filter instead of coercing it. const CONTEXTS: readonly string[] = ['page', 'catalogue', 'modal']; const MAX_MESSAGE = 500; // Kept well short of the 4000 this endpoint originally used. The endpoint is // unauthenticated and the rate limiter is per-address, so a distributed // writer sending a few hundred cheap requests a day was still tens of // megabytes against Docker's default json-file log driver, which has no size // cap on its own. 1000 characters is roughly fifteen stack frames — enough to // identify a throw — and keeps the worst-case record under 3 KB. const MAX_STACK = 1000; const MAX_COMPONENT_STACK = 1000; const MAX_PATH = 200; // True for CR, LF and every other C0 control character, plus DEL (the // C0 range is code points 0 through 31; DEL is 127). Written as a numeric // comparison rather than a control-character regex literal so the source // never has to embed a raw control character or an escape sequence for one. const LAST_C0_CODE = 31; const DEL_CODE = 127; function isControlCharCode(code: number): boolean { return code <= LAST_C0_CODE || code === DEL_CODE; } // Strips CR, LF and other control characters from a string, replacing each // with a single space. The endpoint is unauthenticated, so without this a // caller could embed a newline in any field to forge what looks like a // second [client-error] line in the shared server log. The replacement is // 1-for-1 (one control character becomes one space), so it cannot change // the string's length either way. function sanitize(value: string): string { let result = ''; for (const char of value) { result += isControlCharCode(char.codePointAt(0) ?? 0) ? ' ' : char; } return result; } // Anything that is not a string becomes empty rather than 'undefined' or // '[object Object]', so a malformed field cannot dress itself up as content. function clip(value: unknown, max: number): string { if (typeof value !== 'string') { return ''; } // Sanitize before truncating, not after. Because the substitution above is // 1-for-1, sanitizing first cannot push the stored length past `max` — an // escaping scheme that expanded a control character into multiple visible // characters would need the opposite order to keep that same guarantee, so // the two are not interchangeable and must not be reordered without // re-checking this. const sanitized = sanitize(value); return sanitized.length > max ? `${sanitized.slice(0, max)}… [truncated]` : sanitized; } // No asyncRoute: this handler is synchronous, so there is no promise for the // error middleware to miss. router.post('/', clientErrorLimiter, (req: Request, res: Response) => { const context: unknown = req.body?.context; if (typeof context !== 'string' || !CONTEXTS.includes(context)) { return res.status(400).json({ error: 'invalid context' }); } console.error( `[client-error] context=${context} path=${clip(req.body?.path, MAX_PATH)}\n` + ` message: ${clip(req.body?.message, MAX_MESSAGE)}\n` + ` stack: ${clip(req.body?.stack, MAX_STACK)}\n` + ` componentStack: ${clip(req.body?.componentStack, MAX_COMPONENT_STACK)}` ); res.status(204).end(); }); export default router;