/** * Webhook payloads are provider-controlled untrusted strings. An unhandled * `JSON.parse` throw crashes the handler, returns 500. The provider then * retries delivery per its own (bounded) policy and eventually drops the * event — webhook data is silently lost. Loss is undetectable: no error * metric fires, the provider just stops sending. * * Wrap every `JSON.parse(...)` in a `try` block (or call it from inside a * `catch` handler — already in error-handling territory). * * Detection covers two scopes: * 1. Files matching /webhook.*\.ts$/i (filename signal) * 2. Functions typed as Parse/AcknowledgeWebhook(s)Handler from the * integration-sdk, regardless of filename (AST signal — catches * handlers in `parseEvents.ts`, `eventDispatcher.ts`, etc.) */ import type { Rule } from 'eslint'; import type * as ESTree from 'estree'; const WEBHOOK_FILE_REGEX = /webhook.*\.ts$/i; const WEBHOOK_HANDLER_TYPES = new Set([ 'ParseWebhookHandler', 'ParseWebhooksHandler', 'AcknowledgeWebhookHandler', 'AcknowledgeWebhooksHandler', ]); type NodeWithParent = ESTree.Node & { parent?: NodeWithParent }; function isJsonParseCall(node: ESTree.Node): boolean { if (node.type !== 'CallExpression') { return false; } const callee = node.callee; if (!callee || callee.type !== 'MemberExpression') { return false; } if (!callee.object || callee.object.type !== 'Identifier' || callee.object.name !== 'JSON') { return false; } if (callee.property.type === 'Identifier' && callee.property.name === 'parse') { return true; } if (callee.property.type === 'Literal' && callee.property.value === 'parse') { return true; } return false; } // Function boundaries are NOT a hard stop: an arrow callback synchronously // invoked inside `try { arr.map((r) => JSON.parse(r)) }` is genuinely covered. // A function that itself contains no try will walk to Program and return false. function isCoveredByTryOrCatch(node: Rule.Node): boolean { let current: NodeWithParent = node as unknown as NodeWithParent; let parent: NodeWithParent | undefined = current.parent; while (parent) { if (parent.type === 'CatchClause') { return true; } if (parent.type === 'TryStatement') { const tryStmt = parent as unknown as ESTree.TryStatement; if (tryStmt.block === (current as unknown as ESTree.Node)) { return true; } } current = parent; parent = parent.parent; } return false; } // Detect arrow/function expression assigned to a variable typed as one of // the webhook handler types from integration-sdk: // const parseWebhooks: ParseWebhooksHandler = async (ctx) => { ... } function isWebhookHandlerFunction(fnNode: NodeWithParent): boolean { const parent = fnNode.parent; if (parent?.type !== 'VariableDeclarator') { return false; } const declarator = parent as unknown as ESTree.VariableDeclarator & { id: ESTree.Identifier & { typeAnnotation?: { typeAnnotation: { type: string; typeName?: { type: string; name: string }; }; }; }; }; const id = declarator.id; if (id.type !== 'Identifier' || !id.typeAnnotation) { return false; } const ta = id.typeAnnotation.typeAnnotation; if (ta.type !== 'TSTypeReference' || !ta.typeName || ta.typeName.type !== 'Identifier') { return false; } return WEBHOOK_HANDLER_TYPES.has(ta.typeName.name); } function isInsideWebhookHandler(node: Rule.Node): boolean { let p: NodeWithParent | undefined = (node as unknown as NodeWithParent).parent; while (p) { if (p.type === 'ArrowFunctionExpression' || p.type === 'FunctionExpression') { if (isWebhookHandlerFunction(p)) { return true; } } p = p.parent; } return false; } const rule: Rule.RuleModule = { meta: { type: 'problem', docs: { description: 'In webhook files, JSON.parse must be wrapped in a try block — webhook payloads are untrusted and JSON.parse throws crash the handler.', recommended: true, }, schema: [], messages: { missingTry: 'JSON.parse on a webhook payload must be wrapped in a try block. Webhook bodies are provider-controlled untrusted strings; an unhandled throw returns 500, the provider retries per its bounded policy, and the event is eventually dropped — silent data loss. Wrap the call in `try { ... } catch (e) { logger.warn(...); ... }`.', }, }, create(context) { const filename = context.filename ?? ''; const fileMatches = WEBHOOK_FILE_REGEX.test(filename); return { CallExpression(node) { if (!isJsonParseCall(node)) { return; } if (!fileMatches && !isInsideWebhookHandler(node)) { return; } if (isCoveredByTryOrCatch(node)) { return; } context.report({ node, messageId: 'missingTry', }); }, }; }, }; export default rule;