/** * Inside a `catch` block in a handler or route, do NOT propagate the provider's * `error.message` (or `err.message`, `e.message`, etc.) into the message * argument of an `HttpErrors.*` throw. The provider's error string crosses the * HTTP boundary into Unito's response, where it can carry tokens, customer * PII, or internal field names back to upstream callers and logs. * * Bad: * } catch (error) { * throw new HttpErrors.BadRequestError(error.message); * throw new HttpErrors.X(`upload failed: ${err.message}`); * throw new HttpErrors.Y(error instanceof Error ? error.message : String(error)); * } * * Good — strip provider message, surface a stable summary instead: * } catch (error) { * context.logger.warn('upload failed', { error: error.message }); * throw new HttpErrors.UnprocessableEntityError('Upload failed'); * } * * Scope: * Fires only in `**\/src/handlers/**` and `**\/src/routes/**`. Rate-limiter * files under those paths are excluded (intentional internal-lib message * rethrow on rate-limit responses). */ import type { Rule } from 'eslint'; import type * as ESTree from 'estree'; import { isInsideCatchClause, isHttpErrorsCallee } from '../utils.js'; const SCOPE_FILE_RE = /\/src\/(handlers|routes)\//; const RATE_LIMITER_RE = /[Rr]ate[Ll]imit/; const ERROR_IDENTIFIER_RE = /^(err|error|e|originalError|cause)$/i; function getPropertyName(property: ESTree.Node | undefined | null): string | null { if (!property) { return null; } if (property.type === 'Identifier') { return property.name; } if (property.type === 'Literal' && typeof property.value === 'string') { return property.value; } return null; } function isErrorMessageAccess(memberExpression: ESTree.Node): boolean { if (memberExpression.type !== 'MemberExpression') { return false; } if (getPropertyName(memberExpression.property) !== 'message') { return false; } if (memberExpression.object.type !== 'Identifier') { return false; } return ERROR_IDENTIFIER_RE.test(memberExpression.object.name); } function containsErrorMessageAccess(node: ESTree.Node | null | undefined): boolean { if (!node) { return false; } if (node.type === 'MemberExpression') { if (isErrorMessageAccess(node)) { return true; } return containsErrorMessageAccess(node.object) || containsErrorMessageAccess(node.property); } if (node.type === 'TemplateLiteral') { return node.expressions.some(e => containsErrorMessageAccess(e)); } if (node.type === 'ConditionalExpression') { return ( containsErrorMessageAccess(node.test) || containsErrorMessageAccess(node.consequent) || containsErrorMessageAccess(node.alternate) ); } if (node.type === 'BinaryExpression' || node.type === 'LogicalExpression') { return containsErrorMessageAccess(node.left) || containsErrorMessageAccess(node.right); } if (node.type === 'CallExpression' || node.type === 'NewExpression') { return node.arguments.some(arg => containsErrorMessageAccess(arg as ESTree.Node)); } // TS-specific node types (cast / non-null assertion). `estree` doesn't declare // them but @typescript-eslint/parser emits them; treat them as transparent // wrappers and recurse on .expression. const ts = node as unknown as { type: string; expression?: ESTree.Node }; if (ts.type === 'TSAsExpression' || ts.type === 'TSNonNullExpression') { return containsErrorMessageAccess(ts.expression); } return false; } const rule: Rule.RuleModule = { meta: { type: 'problem', docs: { description: 'Disallow rethrowing the provider error message into the HTTP response (`HttpErrors.X(err.message)`); strip the provider message and throw a stable summary across the boundary', recommended: true, }, schema: [], messages: { errorMessageRethrow: 'Do not propagate `error.message` (or `err.message`/`e.message`) into a `HttpErrors.*` throw. Provider error strings can carry tokens, PII, or internal field names — rethrowing them across the HTTP boundary turns the connector into an exfiltration path. Log the original message via the structured logger (key-based redaction) and throw a stable, provider-agnostic summary instead.', }, }, create(context) { const filename = context.filename ?? ''; if (!SCOPE_FILE_RE.test(filename)) { return {}; } if (RATE_LIMITER_RE.test(filename)) { return {}; } return { ThrowStatement(node) { const argument = node.argument; if (!argument || argument.type !== 'NewExpression') { return; } if (!isHttpErrorsCallee(argument.callee)) { return; } if (!isInsideCatchClause(node as Rule.Node)) { return; } const propagates = argument.arguments.some(arg => containsErrorMessageAccess(arg as ESTree.Node)); if (!propagates) { return; } context.report({ node, messageId: 'errorMessageRethrow' }); }, }; }, }; export default rule;