@unito/integration-sdk
Version:
Integration SDK
114 lines (113 loc) • 3.9 kB
JavaScript
const WEBHOOK_FILE_REGEX = /webhook.*\.ts$/i;
const WEBHOOK_HANDLER_TYPES = new Set([
'ParseWebhookHandler',
'ParseWebhooksHandler',
'AcknowledgeWebhookHandler',
'AcknowledgeWebhooksHandler',
]);
function isJsonParseCall(node) {
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) {
let current = node;
let parent = current.parent;
while (parent) {
if (parent.type === 'CatchClause') {
return true;
}
if (parent.type === 'TryStatement') {
const tryStmt = parent;
if (tryStmt.block === current) {
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) {
const parent = fnNode.parent;
if (parent?.type !== 'VariableDeclarator') {
return false;
}
const declarator = parent;
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) {
let p = node.parent;
while (p) {
if (p.type === 'ArrowFunctionExpression' || p.type === 'FunctionExpression') {
if (isWebhookHandlerFunction(p)) {
return true;
}
}
p = p.parent;
}
return false;
}
const rule = {
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;