@unito/integration-sdk
Version:
Integration SDK
155 lines (143 loc) • 5.02 kB
text/typescript
/**
* 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<string>([
'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;