eslint-plugin-webhook-safety 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +20 -0
- package/LICENSE +21 -0
- package/README.md +85 -2
- package/dist/index.cjs +574 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +27 -0
- package/dist/index.d.ts +27 -0
- package/dist/index.js +572 -0
- package/dist/index.js.map +1 -0
- package/docs/rules/bullmq-require-job-id.md +40 -0
- package/docs/rules/require-idempotency.md +63 -0
- package/docs/rules/require-raw-body.md +64 -0
- package/docs/rules/require-signature-verification.md +52 -0
- package/package.json +79 -4
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/utils.ts","../src/rules/bullmq-require-job-id.ts","../src/rules/require-idempotency.ts","../src/rules/require-raw-body.ts","../src/rules/require-signature-verification.ts","../src/index.ts"],"sourcesContent":["import type { Rule } from 'eslint';\nimport type * as ESTree from 'estree';\n\nexport type Node = ESTree.Node & { parent?: Node };\nexport type FunctionNode =\n ESTree.FunctionDeclaration | ESTree.FunctionExpression | ESTree.ArrowFunctionExpression;\n\nconst REPO = 'https://github.com/Krrishkumar125/eslint-plugin-webhook-safety';\nexport const docsUrl = (name: string): string => `${REPO}/blob/main/docs/rules/${name}.md`;\n\nexport const WEBHOOK_PATH = /webhook|\\/hooks?(?:\\/|$)/i;\nconst CONSTRUCT_EVENT = new Set(['constructEvent', 'constructEventAsync']);\nconst ROUTE_METHODS = new Set(['post', 'put', 'all']);\n\nexport function isFunction(\n node: Node | null | undefined,\n): node is FunctionNode & { parent?: Node } {\n return (\n !!node &&\n (node.type === 'FunctionDeclaration' ||\n node.type === 'FunctionExpression' ||\n node.type === 'ArrowFunctionExpression')\n );\n}\n\n/** Depth-first walk over every descendant node (including the root). */\nexport function walk(root: Node, visit: (node: Node) => void): void {\n const stack: Node[] = [root];\n while (stack.length) {\n const node = stack.pop() as Node;\n visit(node);\n for (const key of Object.keys(node)) {\n if (key === 'parent') continue;\n const value = (node as unknown as Record<string, unknown>)[key];\n const children = Array.isArray(value) ? value : [value];\n for (const child of children) {\n if (child && typeof child === 'object' && typeof (child as Node).type === 'string') {\n stack.push(child as Node);\n }\n }\n }\n }\n}\n\n/** The name of the called function: `a.b.c()` -> \"c\", `fn()` -> \"fn\". */\nexport function calleeName(node: ESTree.CallExpression | ESTree.NewExpression): string | undefined {\n const callee = node.callee;\n if (callee.type === 'Identifier') return callee.name;\n if (\n callee.type === 'MemberExpression' &&\n !callee.computed &&\n callee.property.type === 'Identifier'\n ) {\n return callee.property.name;\n }\n return undefined;\n}\n\n/** `express.json()` -> [\"express\", \"json\"] for a simple `object.method()` call. */\nexport function memberCall(node: Node): [string, string] | undefined {\n if (node.type !== 'CallExpression') return undefined;\n const callee = node.callee;\n if (\n callee.type === 'MemberExpression' &&\n !callee.computed &&\n callee.object.type === 'Identifier' &&\n callee.property.type === 'Identifier'\n ) {\n return [callee.object.name, callee.property.name];\n }\n return undefined;\n}\n\nconst PARSER_OWNERS = new Set(['express', 'bodyParser', 'bodyparser']);\nexport function isJsonParser(node: Node): boolean {\n const m = memberCall(node);\n return !!m && PARSER_OWNERS.has(m[0]) && m[1] === 'json';\n}\nexport function isRawParser(node: Node): boolean {\n const m = memberCall(node);\n return !!m && PARSER_OWNERS.has(m[0]) && (m[1] === 'raw' || m[1] === 'text');\n}\nexport function isBodyParser(node: Node): boolean {\n const m = memberCall(node);\n return !!m && PARSER_OWNERS.has(m[0]) && ['json', 'raw', 'text', 'urlencoded'].includes(m[1]);\n}\n\nexport function isConstructEvent(node: Node): node is ESTree.CallExpression {\n if (node.type !== 'CallExpression') return false;\n const name = calleeName(node);\n return !!name && CONSTRUCT_EVENT.has(name);\n}\n\nexport function findAll<T extends Node>(root: Node, test: (n: Node) => n is T): T[] {\n const out: T[] = [];\n walk(root, (n) => {\n if (test(n)) out.push(n);\n });\n return out;\n}\n\nexport function enclosingFunction(node: Node): FunctionNode | undefined {\n let cur = node.parent;\n while (cur) {\n if (isFunction(cur)) return cur;\n cur = cur.parent;\n }\n return undefined;\n}\n\nfunction literalString(node: Node | undefined): string | undefined {\n if (!node) return undefined;\n if (node.type === 'Literal' && typeof node.value === 'string') return node.value;\n if (node.type === 'TemplateLiteral' && node.expressions.length === 0) {\n return node.quasis[0]?.value.cooked ?? undefined;\n }\n return undefined;\n}\n\nexport interface Route {\n call: ESTree.CallExpression;\n /** `app` in `app.post(...)`, when it is a plain identifier. */\n owner?: string;\n path: string;\n middlewares: Node[];\n handler: FunctionNode;\n}\n\n/** `app.post('/webhook', ...middlewares, handler)` (Express, Hono, Fastify-style routers). */\nexport function asRoute(node: Node): Route | undefined {\n if (node.type !== 'CallExpression') return undefined;\n const callee = node.callee;\n if (\n callee.type !== 'MemberExpression' ||\n callee.computed ||\n callee.property.type !== 'Identifier'\n ) {\n return undefined;\n }\n if (!ROUTE_METHODS.has(callee.property.name)) return undefined;\n const args = node.arguments as Node[];\n const path = literalString(args[0]);\n const handler = args[args.length - 1];\n if (path === undefined || args.length < 2 || !isFunction(handler)) return undefined;\n return {\n call: node,\n owner: callee.object.type === 'Identifier' ? callee.object.name : undefined,\n path,\n middlewares: args.slice(1, -1),\n handler,\n };\n}\n\n/** Exported Next.js App Router `POST` handler, e.g. `export async function POST(req)`. */\nexport function isNextPostHandler(fn: Node): boolean {\n const parent = (fn as Node).parent;\n if (fn.type === 'FunctionDeclaration') {\n return fn.id?.name === 'POST' && parent?.type === 'ExportNamedDeclaration';\n }\n if (parent?.type === 'VariableDeclarator') {\n return (\n parent.id.type === 'Identifier' &&\n parent.id.name === 'POST' &&\n parent.parent?.parent?.type === 'ExportNamedDeclaration'\n );\n }\n return false;\n}\n\nexport function isWebhookFile(context: Rule.RuleContext): boolean {\n return WEBHOOK_PATH.test(context.filename.replace(/\\\\/g, '/'));\n}\n\n/** Name bound to the result of a constructEvent call, or destructured `id` presence. */\nexport function eventBinding(\n call: ESTree.CallExpression,\n): { kind: 'identifier'; name: string } | { kind: 'pattern'; hasId: boolean } | undefined {\n let cur = (call as Node).parent;\n if (cur?.type === 'AwaitExpression') cur = cur.parent;\n let target: Node | undefined;\n if (cur?.type === 'VariableDeclarator') target = cur.id as Node;\n else if (cur?.type === 'AssignmentExpression') target = cur.left as Node;\n if (!target) return undefined;\n if (target.type === 'Identifier') return { kind: 'identifier', name: target.name };\n if (target.type === 'ObjectPattern') {\n const hasId = target.properties.some(\n (p) =>\n p.type === 'RestElement' ||\n (p.key.type === 'Identifier' && p.key.name === 'id') ||\n (p.key.type === 'Literal' && p.key.value === 'id'),\n );\n return { kind: 'pattern', hasId };\n }\n return undefined;\n}\n","import type { Rule } from 'eslint';\nimport type * as ESTree from 'estree';\nimport {\n asRoute,\n docsUrl,\n eventBinding,\n enclosingFunction,\n findAll,\n isConstructEvent,\n isNextPostHandler,\n isWebhookFile,\n WEBHOOK_PATH,\n type FunctionNode,\n type Node,\n} from '../utils.js';\n\nconst QUEUE_NAME = /queue$/i;\n\nconst rule: Rule.RuleModule = {\n meta: {\n type: 'problem',\n hasSuggestions: true,\n docs: {\n description:\n 'Require a jobId (or deduplication id) when a webhook handler adds a BullMQ job, so retried deliveries do not enqueue duplicates',\n recommended: true,\n url: docsUrl('bullmq-require-job-id'),\n },\n schema: [],\n messages: {\n missing:\n 'Webhook deliveries are retried, so this queue.add() will enqueue the same work twice. Pass a stable jobId such as the event id.',\n addJobId: 'Add { jobId: {{name}}.id }',\n },\n },\n create(context) {\n const webhookFile = isWebhookFile(context);\n const queues = new Set<string>();\n\n function isWebhookFunction(fn: FunctionNode): boolean {\n const parent = (fn as Node).parent;\n if (parent) {\n const route = asRoute(parent);\n if (route && route.handler === fn && WEBHOOK_PATH.test(route.path)) return true;\n }\n if (webhookFile && isNextPostHandler(fn as Node)) return true;\n return findAll(fn as Node, isConstructEvent).some((c) => enclosingFunction(c) === fn);\n }\n\n function eventName(fn: FunctionNode): string | undefined {\n for (const c of findAll(fn as Node, isConstructEvent)) {\n const b = eventBinding(c);\n if (b?.kind === 'identifier') return b.name;\n }\n return undefined;\n }\n\n function isQueue(obj: Node): boolean {\n if (obj.type === 'Identifier') return queues.has(obj.name) || QUEUE_NAME.test(obj.name);\n return (\n obj.type === 'MemberExpression' &&\n !obj.computed &&\n obj.property.type === 'Identifier' &&\n QUEUE_NAME.test(obj.property.name)\n );\n }\n\n return {\n VariableDeclarator(node) {\n if (\n node.id.type === 'Identifier' &&\n node.init?.type === 'NewExpression' &&\n node.init.callee.type === 'Identifier' &&\n node.init.callee.name === 'Queue'\n ) {\n queues.add(node.id.name);\n }\n },\n CallExpression(node) {\n const callee = node.callee;\n if (\n callee.type !== 'MemberExpression' ||\n callee.computed ||\n callee.property.type !== 'Identifier' ||\n callee.property.name !== 'add' ||\n node.arguments.length < 2 ||\n node.arguments.length > 3 ||\n !isQueue(callee.object as Node)\n ) {\n return;\n }\n const opts = node.arguments[2] as Node | undefined;\n if (opts && opts.type !== 'ObjectExpression') return;\n if (\n opts?.type === 'ObjectExpression' &&\n opts.properties.some(\n (p) =>\n p.type === 'SpreadElement' ||\n (p.key.type === 'Identifier' && ['jobId', 'deduplication'].includes(p.key.name)),\n )\n ) {\n return;\n }\n let fn = enclosingFunction(node as Node);\n let webhookFn: FunctionNode | undefined;\n while (fn) {\n if (isWebhookFunction(fn)) {\n webhookFn = fn;\n break;\n }\n fn = enclosingFunction(fn as Node);\n }\n if (!webhookFn) return;\n const name = eventName(webhookFn);\n const suggest: Rule.SuggestionReportDescriptor[] = [];\n if (name) {\n const prop = `jobId: ${name}.id`;\n suggest.push({\n messageId: 'addJobId',\n data: { name },\n fix: (fixer) => {\n if (!opts)\n return fixer.insertTextAfter(node.arguments[1] as ESTree.Node, `, { ${prop} }`);\n const obj = opts as ESTree.ObjectExpression;\n const last = obj.properties[obj.properties.length - 1];\n return last\n ? fixer.insertTextAfter(last, `, ${prop}`)\n : fixer.replaceText(obj, `{ ${prop} }`);\n },\n });\n }\n context.report({ node, messageId: 'missing', suggest });\n },\n };\n },\n};\n\nexport default rule;\n","import type { Rule } from 'eslint';\nimport type * as ESTree from 'estree';\nimport {\n calleeName,\n docsUrl,\n enclosingFunction,\n eventBinding,\n isConstructEvent,\n walk,\n type Node,\n} from '../utils.js';\n\nconst DEFAULT_HELPERS = /idempot|dedup|processed|alreadyHandled|seenEvent/i;\n\n/**\n * Methods that write somewhere when called on an object: ORM/DB writes, queue adds, emails and\n * payment API calls (`db.user.update`, `queue.add`, `resend.emails.send`, `stripe.refunds.create`).\n */\nconst SIDE_EFFECT_METHODS = new Set([\n 'create',\n 'createMany',\n 'insert',\n 'insertOne',\n 'insertMany',\n 'update',\n 'updateOne',\n 'updateMany',\n 'upsert',\n 'delete',\n 'deleteMany',\n 'increment',\n 'decrement',\n 'save',\n 'add',\n 'addBulk',\n 'enqueue',\n 'publish',\n 'send',\n 'sendMail',\n 'sendEmail',\n 'charge',\n 'capture',\n 'refund',\n 'transfer',\n]);\nconst NON_EFFECT_OBJECTS = new Set([\n 'console',\n 'Response',\n 'NextResponse',\n 'JSON',\n 'res',\n 'Math',\n 'Object',\n 'Array',\n]);\n\nfunction rootObject(node: Node): Node {\n let cur = node;\n while (cur.type === 'MemberExpression' || cur.type === 'CallExpression') {\n cur = (cur.type === 'MemberExpression' ? cur.object : cur.callee) as Node;\n }\n return cur;\n}\n\nconst rule: Rule.RuleModule = {\n meta: {\n type: 'suggestion',\n docs: {\n description:\n 'Suggest a duplicate-event check (using event.id) before a webhook handler performs side effects directly',\n recommended: true,\n url: docsUrl('require-idempotency'),\n },\n schema: [\n {\n type: 'object',\n properties: {\n idempotencyFunctions: { type: 'array', items: { type: 'string' } },\n strict: { type: 'boolean' },\n },\n additionalProperties: false,\n },\n ],\n messages: {\n missing:\n 'Consider a duplicate-event check: Stripe can deliver `{{name}}` more than once, and this write runs without any use of `{{name}}.id`. Record processed event ids (unique insert) before side effects, or ignore if this write is idempotent. See {{url}}',\n },\n },\n create(context) {\n const options = (context.options[0] ?? {}) as {\n idempotencyFunctions?: string[];\n strict?: boolean;\n };\n const helpers = new Set<string>(options.idempotencyFunctions ?? []);\n const url = docsUrl('require-idempotency');\n\n function isSideEffect(call: ESTree.CallExpression): boolean {\n const callee = call.callee as Node;\n if (callee.type === 'MemberExpression') {\n const root = rootObject(callee);\n if (root.type === 'Identifier' && NON_EFFECT_OBJECTS.has(root.name)) return false;\n const name = calleeName(call);\n if (name && SIDE_EFFECT_METHODS.has(name)) return true;\n }\n // Helper calls (local or imported) may dedupe internally, so only count them in strict mode.\n return !!options.strict && !isConstructEvent(call as Node) && call.arguments.length > 0;\n }\n\n return {\n CallExpression(node) {\n if (!isConstructEvent(node as Node)) return;\n const binding = eventBinding(node);\n if (!binding) return;\n if (binding.kind === 'pattern' && binding.hasId) return;\n const name = binding.kind === 'identifier' ? binding.name : 'event';\n const fn = enclosingFunction(node as Node);\n if (!fn) return;\n const after = node.range?.[1] ?? 0;\n let handled = false;\n let firstEffect: ESTree.CallExpression | undefined;\n walk(fn as Node, (n) => {\n if (handled) return;\n if (\n n.type === 'MemberExpression' &&\n n.object.type === 'Identifier' &&\n n.object.name === name &&\n !n.computed &&\n n.property.type === 'Identifier' &&\n n.property.name === 'id'\n ) {\n handled = true;\n } else if (\n (n.type === 'ReturnStatement' &&\n n.argument?.type === 'Identifier' &&\n n.argument.name === name) ||\n (n.type === 'ArrowFunctionExpression' &&\n n.body.type === 'Identifier' &&\n n.body.name === name)\n ) {\n // A wrapper that only verifies and returns the event; the caller handles duplicates.\n handled = true;\n } else if (n.type === 'CallExpression') {\n const callee = calleeName(n);\n if (callee && (helpers.has(callee) || DEFAULT_HELPERS.test(callee))) handled = true;\n const isConsole =\n n.callee.type === 'MemberExpression' &&\n n.callee.object.type === 'Identifier' &&\n n.callee.object.name === 'console';\n // Whole event handed to another function: assume it may dedupe there.\n if (!isConsole && n.arguments.some((a) => a.type === 'Identifier' && a.name === name)) {\n handled = true;\n }\n if (\n (n.range?.[0] ?? 0) >= after &&\n isSideEffect(n) &&\n (!firstEffect || (n.range?.[0] ?? 0) < (firstEffect.range?.[0] ?? 0))\n ) {\n firstEffect = n;\n }\n }\n });\n if (!handled && firstEffect) {\n context.report({ node: firstEffect, messageId: 'missing', data: { name, url } });\n }\n },\n };\n },\n};\n\nexport default rule;\n","import type { Rule } from 'eslint';\nimport type * as ESTree from 'estree';\nimport {\n asRoute,\n docsUrl,\n enclosingFunction,\n findAll,\n isConstructEvent,\n isFunction,\n isJsonParser,\n isRawParser,\n type Node,\n} from '../utils.js';\n\n/** `express.json({ verify })` keeps the raw bytes (usually on `req.rawBody`), so it is fine. */\nfunction parsesAway(node: Node): boolean {\n if (!isJsonParser(node)) return false;\n const opts = (node as ESTree.CallExpression).arguments[0];\n return !(\n opts?.type === 'ObjectExpression' &&\n opts.properties.some(\n (p) => p.type === 'Property' && p.key.type === 'Identifier' && p.key.name === 'verify',\n )\n );\n}\n\n/** constructEvent(req.body, ...) somewhere in the handler: the payload is whatever the parser left. */\nfunction usesParsedBody(fn: Node): boolean {\n return findAll(fn, isConstructEvent).some((c) => {\n const payload = c.arguments[0];\n return (\n payload?.type === 'MemberExpression' &&\n !payload.computed &&\n payload.property.type === 'Identifier' &&\n payload.property.name === 'body'\n );\n });\n}\n\nconst rule: Rule.RuleModule = {\n meta: {\n type: 'problem',\n hasSuggestions: true,\n docs: {\n description:\n 'Require the raw request body for Stripe signature verification (no JSON parsing before constructEvent)',\n recommended: true,\n url: docsUrl('require-raw-body'),\n },\n schema: [],\n messages: {\n jsonMiddleware:\n 'express.json() parses the body before the Stripe signature check, so constructEvent will always fail. Use express.raw() on this route.',\n globalJson:\n '`{{owner}}.use(express.json())` on line {{line}} runs before this webhook route and parses the body, so constructEvent will fail. Register the webhook route first or give it express.raw().',\n parsedJson:\n 'The body is parsed with .json() before the Stripe signature check. Read the raw body with .text() and pass it to constructEvent.',\n stringified:\n 'JSON.stringify() of a parsed body is not byte-identical to what Stripe signed. Pass the raw body string or Buffer.',\n useRaw: \"Replace with express.raw({ type: 'application/json' })\",\n useText: 'Read the body with .text() instead',\n },\n },\n create(context) {\n const globalParsers: { owner: string; node: ESTree.CallExpression }[] = [];\n\n function handlerVerifies(fn: Node): boolean {\n return findAll(fn, isConstructEvent).length > 0;\n }\n\n return {\n CallExpression(node) {\n const n = node as Node & ESTree.CallExpression;\n const callee = n.callee;\n // app.use(express.json()) / app.use('/', express.json())\n if (\n callee.type === 'MemberExpression' &&\n !callee.computed &&\n callee.property.type === 'Identifier' &&\n callee.property.name === 'use' &&\n callee.object.type === 'Identifier'\n ) {\n const args = n.arguments as Node[];\n const first = args[0];\n const pathless = !(first?.type === 'Literal' && first.value !== '/');\n if (pathless && args.some(parsesAway)) {\n globalParsers.push({ owner: callee.object.name, node: n });\n }\n }\n\n const route = asRoute(n);\n if (route && handlerVerifies(route.handler) && usesParsedBody(route.handler)) {\n const inlineJson = route.middlewares.filter(parsesAway);\n for (const mw of inlineJson) {\n context.report({\n node: mw,\n messageId: 'jsonMiddleware',\n suggest: [\n {\n messageId: 'useRaw',\n fix: (fixer) =>\n fixer.replaceText(mw, \"express.raw({ type: 'application/json' })\"),\n },\n ],\n });\n }\n if (!inlineJson.length && !route.middlewares.some(isRawParser)) {\n const earlier = globalParsers.find(\n (p) => p.owner === route.owner && (p.node.range?.[0] ?? 0) < (n.range?.[0] ?? 0),\n );\n if (earlier) {\n context.report({\n node: n,\n messageId: 'globalJson',\n data: { owner: earlier.owner, line: String(earlier.node.loc?.start.line) },\n });\n }\n }\n }\n\n if (isConstructEvent(n)) {\n const payload = n.arguments[0] as Node | undefined;\n if (\n payload?.type === 'CallExpression' &&\n payload.callee.type === 'MemberExpression' &&\n payload.callee.object.type === 'Identifier' &&\n payload.callee.object.name === 'JSON' &&\n payload.callee.property.type === 'Identifier' &&\n payload.callee.property.name === 'stringify'\n ) {\n context.report({ node: payload, messageId: 'stringified' });\n }\n const fn = enclosingFunction(n);\n if (!fn) return;\n const params = new Set(\n fn.params.flatMap((p) => (p.type === 'Identifier' ? [p.name] : [])),\n );\n const isRequest = (obj: Node): boolean =>\n (obj.type === 'Identifier' && params.has(obj.name)) ||\n (obj.type === 'MemberExpression' &&\n obj.object.type === 'Identifier' &&\n params.has(obj.object.name) &&\n obj.property.type === 'Identifier' &&\n obj.property.name === 'req');\n walkSameFunction(fn, (c) => {\n if (\n c.type === 'CallExpression' &&\n c.arguments.length === 0 &&\n c.callee.type === 'MemberExpression' &&\n !c.callee.computed &&\n c.callee.property.type === 'Identifier' &&\n c.callee.property.name === 'json' &&\n isRequest(c.callee.object as Node) &&\n (c.range?.[0] ?? 0) < (n.range?.[0] ?? 0)\n ) {\n const prop = c.callee.property;\n context.report({\n node: c,\n messageId: 'parsedJson',\n suggest: [\n { messageId: 'useText', fix: (fixer) => fixer.replaceText(prop, 'text') },\n ],\n });\n }\n });\n }\n },\n };\n },\n};\n\nfunction walkSameFunction(fn: Node, visit: (n: Node) => void): void {\n const body = (fn as ESTree.Function).body as Node;\n const stack: Node[] = [body];\n while (stack.length) {\n const node = stack.pop() as Node;\n visit(node);\n for (const key of Object.keys(node)) {\n if (key === 'parent') continue;\n const value = (node as unknown as Record<string, unknown>)[key];\n for (const child of Array.isArray(value) ? value : [value]) {\n if (child && typeof child === 'object' && typeof (child as Node).type === 'string') {\n if (!isFunction(child as Node)) stack.push(child as Node);\n }\n }\n }\n }\n}\n\nexport default rule;\n","import type { Rule } from 'eslint';\nimport {\n asRoute,\n calleeName,\n docsUrl,\n isBodyParser,\n isNextPostHandler,\n isWebhookFile,\n walk,\n WEBHOOK_PATH,\n type FunctionNode,\n type Node,\n} from '../utils.js';\n\nconst DEFAULT_VERIFY = /verif|signature|constructEvent|timingSafeEqual|unwrap/i;\n/** Authenticated endpoints (e.g. a CRUD API for managing webhook subscriptions) are not receivers. */\nconst AUTH_CALL = /auth|session|apiKey|permission|currentUser|requireUser/i;\n\nconst rule: Rule.RuleModule = {\n meta: {\n type: 'problem',\n docs: {\n description: 'Require webhook handlers to verify the request signature',\n recommended: true,\n url: docsUrl('require-signature-verification'),\n },\n schema: [\n {\n type: 'object',\n properties: { verifyFunctions: { type: 'array', items: { type: 'string' } } },\n additionalProperties: false,\n },\n ],\n messages: {\n missing:\n 'This webhook handler never verifies the request signature, so anyone can forge events. Call stripe.webhooks.constructEvent() (or your provider’s verify function) before trusting the body.',\n },\n },\n create(context) {\n const extra = new Set<string>(\n (context.options[0] as { verifyFunctions?: string[] } | undefined)?.verifyFunctions ?? [],\n );\n const webhookFile = isWebhookFile(context);\n\n function verifies(fn: Node): boolean {\n let found = false;\n const params = new Set(\n (fn as FunctionNode).params.flatMap((p) => (p.type === 'Identifier' ? [p.name] : [])),\n );\n const isRequest = (a: Node): boolean =>\n (a.type === 'Identifier' && params.has(a.name)) ||\n (a.type === 'MemberExpression' &&\n a.object.type === 'Identifier' &&\n params.has(a.object.name) &&\n !a.computed &&\n a.property.type === 'Identifier' &&\n a.property.name === 'req');\n walk(fn, (n) => {\n if (found || (n.type !== 'CallExpression' && n.type !== 'NewExpression')) return;\n // The whole request handed to a helper (often in another file) that may verify it.\n const root = n.callee.type === 'MemberExpression' ? n.callee.object : undefined;\n const builtin =\n root?.type === 'Identifier' &&\n ['console', 'JSON', 'Response', 'NextResponse', 'res', 'Object'].includes(root.name);\n if (!builtin && (n.arguments as Node[]).some(isRequest)) {\n found = true;\n return;\n }\n const name = calleeName(n);\n if (name && (extra.has(name) || DEFAULT_VERIFY.test(name) || AUTH_CALL.test(name)))\n found = true;\n });\n return found;\n }\n\n function check(fn: Node): void {\n if (!verifies(fn)) context.report({ node: fn, messageId: 'missing' });\n }\n\n return {\n CallExpression(node) {\n const route = asRoute(node as Node);\n if (!route || !WEBHOOK_PATH.test(route.path)) return;\n // Any non-body-parser middleware may be doing the verification.\n if (route.middlewares.some((m) => !isBodyParser(m))) return;\n check(route.handler);\n },\n 'FunctionDeclaration, FunctionExpression, ArrowFunctionExpression'(node: Rule.Node) {\n if (webhookFile && isNextPostHandler(node as Node)) check(node as Node);\n },\n };\n },\n};\n\nexport default rule;\n","import type { ESLint, Linter, Rule } from 'eslint';\nimport bullmqRequireJobId from './rules/bullmq-require-job-id.js';\nimport requireIdempotency from './rules/require-idempotency.js';\nimport requireRawBody from './rules/require-raw-body.js';\nimport requireSignatureVerification from './rules/require-signature-verification.js';\n\nconst rules: Record<string, Rule.RuleModule> = {\n 'require-raw-body': requireRawBody,\n 'require-signature-verification': requireSignatureVerification,\n 'require-idempotency': requireIdempotency,\n 'bullmq-require-job-id': bullmqRequireJobId,\n};\n\nconst recommendedRules: Linter.RulesRecord = {\n 'webhook-safety/require-raw-body': 'error',\n 'webhook-safety/require-signature-verification': 'error',\n 'webhook-safety/require-idempotency': 'warn',\n 'webhook-safety/bullmq-require-job-id': 'warn',\n};\nconst strictRules: Linter.RulesRecord = Object.fromEntries(\n Object.keys(recommendedRules).map((k) => [k, 'error']),\n);\n\nconst plugin = {\n meta: { name: 'eslint-plugin-webhook-safety', version: '0.2.0' },\n rules,\n configs: {} as {\n recommended: Linter.Config & { rules: Linter.RulesRecord };\n strict: Linter.Config & { rules: Linter.RulesRecord };\n 'legacy-recommended': { plugins: string[]; rules: Linter.RulesRecord };\n 'legacy-strict': { plugins: string[]; rules: Linter.RulesRecord };\n },\n} satisfies ESLint.Plugin;\n\nplugin.configs.recommended = {\n name: 'webhook-safety/recommended',\n plugins: { 'webhook-safety': plugin as ESLint.Plugin },\n rules: recommendedRules,\n};\nplugin.configs.strict = {\n name: 'webhook-safety/strict',\n plugins: { 'webhook-safety': plugin as ESLint.Plugin },\n rules: strictRules,\n};\nplugin.configs['legacy-recommended'] = { plugins: ['webhook-safety'], rules: recommendedRules };\nplugin.configs['legacy-strict'] = { plugins: ['webhook-safety'], rules: strictRules };\n\nexport default plugin;\n"],"mappings":"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","names":["rule","rule","rule"]}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# bullmq-require-job-id
|
|
2
|
+
|
|
3
|
+
Require a `jobId` (or `deduplication` id) when a webhook handler adds a BullMQ job.
|
|
4
|
+
|
|
5
|
+
💼 `recommended` (warn), `strict` (error) · 💡 has suggestions
|
|
6
|
+
|
|
7
|
+
## Why
|
|
8
|
+
|
|
9
|
+
Retried webhook deliveries run your handler again. `queue.add()` without a stable id enqueues the
|
|
10
|
+
work a second time. With `jobId: event.id`, BullMQ ignores the duplicate add
|
|
11
|
+
([docs](https://docs.bullmq.io/guide/jobs/job-ids)).
|
|
12
|
+
|
|
13
|
+
## What it reports
|
|
14
|
+
|
|
15
|
+
`<queue>.add(name, data)` or `<queue>.add(name, data, { ...no jobId/deduplication })` inside a webhook
|
|
16
|
+
handler (a function that calls `constructEvent`, a webhook route, or a Next.js `POST` in a webhook file).
|
|
17
|
+
Receivers count as queues when they were created with `new Queue(...)` in the file or their name ends in `queue`
|
|
18
|
+
(`emailQueue`, `this.jobsQueue`). Options passed as a variable or with a spread are not checked.
|
|
19
|
+
|
|
20
|
+
## Examples
|
|
21
|
+
|
|
22
|
+
❌ Incorrect
|
|
23
|
+
|
|
24
|
+
```js
|
|
25
|
+
const event = stripe.webhooks.constructEvent(raw, sig, secret);
|
|
26
|
+
await emailQueue.add('receipt', { to: event.data.object.email });
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
✅ Correct
|
|
30
|
+
|
|
31
|
+
```js
|
|
32
|
+
await emailQueue.add('receipt', { to: event.data.object.email }, { jobId: event.id });
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Note: BullMQ job ids must not contain `:`. Stripe ids (`evt_...`) are safe. See also
|
|
36
|
+
[eslint-plugin-bullmq](https://github.com/Krrishkumar125/eslint-plugin-bullmq) `valid-job-id`.
|
|
37
|
+
|
|
38
|
+
## Suggestions
|
|
39
|
+
|
|
40
|
+
Adds `{ jobId: <event>.id }` using the variable that holds the `constructEvent` result.
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# require-idempotency
|
|
2
|
+
|
|
3
|
+
Suggest a duplicate-event check (using `event.id`) when a webhook handler writes directly.
|
|
4
|
+
|
|
5
|
+
💼 `recommended` (warn), `strict` (error)
|
|
6
|
+
|
|
7
|
+
## Why
|
|
8
|
+
|
|
9
|
+
Stripe delivers events **at least once**. It retries on timeouts and non-2xx responses and
|
|
10
|
+
sometimes sends the same event twice. Stripe's docs say to log processed event ids and skip
|
|
11
|
+
ones you have already handled. Without that, users get double credits, double emails and double refunds.
|
|
12
|
+
|
|
13
|
+
## What ## What it reports
|
|
14
|
+
|
|
15
|
+
A `constructEvent(...)` result (stored or destructured) when the same function then performs a
|
|
16
|
+
**direct side effect** and never reads `event.id`. Direct side effects are method calls such as
|
|
17
|
+
`db.x.create/update/upsert/delete/increment`, `queue.add`, `resend.emails.send`,
|
|
18
|
+
`transporter.sendMail` and `stripe.refunds.create`. The report points at the first such write.
|
|
19
|
+
|
|
20
|
+
It is deliberately conservative and stays silent when:
|
|
21
|
+
|
|
22
|
+
- the whole `event` is passed to another function (`handleStripeEvent(event)`), which may dedupe;
|
|
23
|
+
- processing happens in **helpers** (local or imported), even if they receive data derived from the event
|
|
24
|
+
(`recordTrialCard({ subscriptionId })`), because they are often idempotent by design
|
|
25
|
+
(for example by checking state already stored in Stripe);
|
|
26
|
+
- a call's name looks like a dedupe helper (`/idempot|dedup|processed|alreadyHandled|seenEvent/i`), or is listed in `idempotencyFunctions`;
|
|
27
|
+
- the function only verifies and returns the event.
|
|
28
|
+
|
|
29
|
+
If a write is idempotent on its own (an upsert keyed by the Stripe object id), it is fine to ignore the warning.
|
|
30
|
+
|
|
31
|
+
## Options
|
|
32
|
+
|
|
33
|
+
```js
|
|
34
|
+
'webhook-safety/require-idempotency': ['warn', {
|
|
35
|
+
idempotencyFunctions: ['once'], // calls that dedupe
|
|
36
|
+
strict: false, // true: helper calls with arguments also count as side effects
|
|
37
|
+
}]
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
les
|
|
41
|
+
|
|
42
|
+
❌ Incorrect
|
|
43
|
+
|
|
44
|
+
```js
|
|
45
|
+
const event = stripe.webhooks.constructEvent(req.body, sig, secret);
|
|
46
|
+
if (event.type === 'checkout.session.completed') {
|
|
47
|
+
await db.user.update({ where: { id: userId }, data: { credits: { increment: 100 } } });
|
|
48
|
+
}
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
✅ Correct
|
|
52
|
+
|
|
53
|
+
```js
|
|
54
|
+
const event = stripe.webhooks.constructEvent(req.body, sig, secret);
|
|
55
|
+
const fresh = await db.processedEvent.createMany({
|
|
56
|
+
data: [{ id: event.id }],
|
|
57
|
+
skipDuplicates: true,
|
|
58
|
+
});
|
|
59
|
+
if (fresh.count === 0) return res.json({ duplicate: true });
|
|
60
|
+
await db.user.update(/* ... */);
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
`webhook-crash-lab` checks this at runtime (`duplicate-delivery`, `concurrent-duplicates` scenarios).
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# require-raw-body
|
|
2
|
+
|
|
3
|
+
Require the raw request body for Stripe signature verification.
|
|
4
|
+
|
|
5
|
+
💼 `recommended` (error), `strict` (error) · 💡 has suggestions
|
|
6
|
+
|
|
7
|
+
## Why
|
|
8
|
+
|
|
9
|
+
`stripe.webhooks.constructEvent()` checks an HMAC over the **exact bytes** Stripe sent. If
|
|
10
|
+
`express.json()` (or Next.js `req.json()`) parsed the body first, the bytes are gone and every
|
|
11
|
+
event fails with `No signatures found matching the expected signature for payload`. This is the
|
|
12
|
+
single most common Stripe webhook bug (see the
|
|
13
|
+
[Stripe docs](https://docs.stripe.com/webhooks/signature) and
|
|
14
|
+
[stripe-node#2078](https://github.com/stripe/stripe-node/issues/2078)). Re-serializing with
|
|
15
|
+
`JSON.stringify(req.body)` does not help: key order and whitespace differ.
|
|
16
|
+
|
|
17
|
+
## What it reports
|
|
18
|
+
|
|
19
|
+
- `express.json()` / `bodyParser.json()` passed as middleware to a route whose handler calls `constructEvent`.
|
|
20
|
+
- `app.use(express.json())` registered on the same app **before** such a route, when the route has no `express.raw()`/`express.text()` of its own.
|
|
21
|
+
- `await req.json()` (or Hono's `c.req.json()`) before `constructEvent` in the same function.
|
|
22
|
+
- `constructEvent(JSON.stringify(...), ...)`.
|
|
23
|
+
|
|
24
|
+
The middleware checks only fire when `constructEvent` is given `req.body`. `express.json({ verify })`, which keeps
|
|
25
|
+
the raw bytes (for example on `req.rawBody`), is not reported.
|
|
26
|
+
|
|
27
|
+
## Examples
|
|
28
|
+
|
|
29
|
+
❌ Incorrect
|
|
30
|
+
|
|
31
|
+
```js
|
|
32
|
+
app.use(express.json());
|
|
33
|
+
app.post('/webhooks/stripe', (req, res) => {
|
|
34
|
+
const event = stripe.webhooks.constructEvent(req.body, sig, secret);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
export async function POST(req) {
|
|
38
|
+
const body = await req.json();
|
|
39
|
+
const event = stripe.webhooks.constructEvent(JSON.stringify(body), sig, secret);
|
|
40
|
+
}
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
✅ Correct
|
|
44
|
+
|
|
45
|
+
```js
|
|
46
|
+
app.post('/webhooks/stripe', express.raw({ type: 'application/json' }), (req, res) => {
|
|
47
|
+
const event = stripe.webhooks.constructEvent(req.body, sig, secret);
|
|
48
|
+
});
|
|
49
|
+
app.use(express.json()); // after the webhook route
|
|
50
|
+
|
|
51
|
+
export async function POST(req) {
|
|
52
|
+
const body = await req.text();
|
|
53
|
+
const event = stripe.webhooks.constructEvent(body, req.headers.get('stripe-signature'), secret);
|
|
54
|
+
}
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Suggestions
|
|
58
|
+
|
|
59
|
+
- Replace the inline `express.json()` with `express.raw({ type: 'application/json' })`.
|
|
60
|
+
- Replace `req.json()` with `req.text()`.
|
|
61
|
+
|
|
62
|
+
These are suggestions, not autofixes, because the handler may read parsed fields afterwards.
|
|
63
|
+
|
|
64
|
+
Try it for real: [`npx webhook-crash-lab demo`](https://github.com/Krrishkumar125/webhook-crash-lab) shows this failure (`raw-body` scenario) against a running server.
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# require-signature-verification
|
|
2
|
+
|
|
3
|
+
Require webhook handlers to verify the request signature.
|
|
4
|
+
|
|
5
|
+
💼 `recommended` (error), `strict` (error)
|
|
6
|
+
|
|
7
|
+
## Why
|
|
8
|
+
|
|
9
|
+
A webhook URL is public. Without a signature check, anyone who finds it can POST a fake
|
|
10
|
+
`checkout.session.completed` and get free credits.
|
|
11
|
+
|
|
12
|
+
## What it reports
|
|
13
|
+
|
|
14
|
+
A handler that never calls anything verification-like:
|
|
15
|
+
|
|
16
|
+
- `app.post('<path containing "webhook" or /hook>', handler)` (Express, Hono, Fastify-style routers; also `put`/`all`), and
|
|
17
|
+
- an exported Next.js `POST` in a file whose path contains `webhook` (e.g. `app/api/webhooks/stripe/route.ts`).
|
|
18
|
+
|
|
19
|
+
A call counts as verification when its name matches `/verif|signature|constructEvent|timingSafeEqual|unwrap/i`
|
|
20
|
+
(`constructEvent`, `verifySignature`, `wh.verify`, `crypto.timingSafeEqual`, `client.webhooks.unwrap` …).
|
|
21
|
+
|
|
22
|
+
To keep false positives low the rule stays silent when:
|
|
23
|
+
|
|
24
|
+
- the route has any middleware other than a body parser (it may do the verification);
|
|
25
|
+
- the handler passes the request object to another function (`return handleStripeWebhook(req)`), which may verify it in another file;
|
|
26
|
+
- the handler calls something authentication-like (`auth`, `session`, `apiKey`, `permission`, `currentUser`). Authenticated endpoints, such as an API for managing webhook subscriptions, are not webhook receivers.
|
|
27
|
+
|
|
28
|
+
## Options
|
|
29
|
+
|
|
30
|
+
```js
|
|
31
|
+
'webhook-safety/require-signature-verification': ['error', { verifyFunctions: ['assertFromPaddle'] }]
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Examples
|
|
35
|
+
|
|
36
|
+
❌ Incorrect
|
|
37
|
+
|
|
38
|
+
```js
|
|
39
|
+
app.post('/webhooks/stripe', express.raw({ type: '*/*' }), async (req, res) => {
|
|
40
|
+
const event = JSON.parse(req.body);
|
|
41
|
+
await grantCredits(event.data.object);
|
|
42
|
+
});
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
✅ Correct
|
|
46
|
+
|
|
47
|
+
```js
|
|
48
|
+
app.post('/webhooks/stripe', express.raw({ type: '*/*' }), async (req, res) => {
|
|
49
|
+
const event = stripe.webhooks.constructEvent(req.body, req.headers['stripe-signature'], secret);
|
|
50
|
+
await grantCredits(event.data.object);
|
|
51
|
+
});
|
|
52
|
+
```
|
package/package.json
CHANGED
|
@@ -1,6 +1,81 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "eslint-plugin-webhook-safety",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "ESLint rules that catch broken Stripe webhook handlers before deploy: parsed bodies before signature checks, missing verification, missing duplicate-event checks, and BullMQ jobs without a jobId.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Krrish Kumar (https://github.com/Krrishkumar125)",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/Krrishkumar125/eslint-plugin-webhook-safety.git"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://github.com/Krrishkumar125/eslint-plugin-webhook-safety#readme",
|
|
12
|
+
"bugs": "https://github.com/Krrishkumar125/eslint-plugin-webhook-safety/issues",
|
|
13
|
+
"keywords": [
|
|
14
|
+
"eslint",
|
|
15
|
+
"eslintplugin",
|
|
16
|
+
"eslint-plugin",
|
|
17
|
+
"webhook",
|
|
18
|
+
"stripe",
|
|
19
|
+
"idempotency",
|
|
20
|
+
"bullmq",
|
|
21
|
+
"express",
|
|
22
|
+
"nextjs",
|
|
23
|
+
"security"
|
|
24
|
+
],
|
|
25
|
+
"type": "module",
|
|
26
|
+
"main": "./dist/index.cjs",
|
|
27
|
+
"module": "./dist/index.js",
|
|
28
|
+
"types": "./dist/index.d.ts",
|
|
29
|
+
"exports": {
|
|
30
|
+
".": {
|
|
31
|
+
"import": {
|
|
32
|
+
"types": "./dist/index.d.ts",
|
|
33
|
+
"default": "./dist/index.js"
|
|
34
|
+
},
|
|
35
|
+
"require": {
|
|
36
|
+
"types": "./dist/index.d.cts",
|
|
37
|
+
"default": "./dist/index.cjs"
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
"./package.json": "./package.json"
|
|
41
|
+
},
|
|
42
|
+
"files": [
|
|
43
|
+
"dist",
|
|
44
|
+
"docs",
|
|
45
|
+
"CHANGELOG.md"
|
|
46
|
+
],
|
|
47
|
+
"engines": {
|
|
48
|
+
"node": ">=20"
|
|
49
|
+
},
|
|
50
|
+
"scripts": {
|
|
51
|
+
"build": "tsup",
|
|
52
|
+
"typecheck": "tsc --noEmit",
|
|
53
|
+
"lint": "eslint . && prettier --check .",
|
|
54
|
+
"format": "prettier --write .",
|
|
55
|
+
"test": "vitest run",
|
|
56
|
+
"test:coverage": "vitest run --coverage",
|
|
57
|
+
"check:exports": "publint && attw --pack . --profile node16 --ignore-rules missing-export-equals",
|
|
58
|
+
"prepublishOnly": "npm run lint && npm run typecheck && npm test && npm run build",
|
|
59
|
+
"smoke": "npm run build && node smoke/run.mjs"
|
|
60
|
+
},
|
|
61
|
+
"peerDependencies": {
|
|
62
|
+
"eslint": ">=8.57.0"
|
|
63
|
+
},
|
|
64
|
+
"devDependencies": {
|
|
65
|
+
"@arethetypeswrong/cli": "^0.18.5",
|
|
66
|
+
"@eslint/js": "^10.0.1",
|
|
67
|
+
"@types/estree": "^1.0.9",
|
|
68
|
+
"@types/node": "^20.19.43",
|
|
69
|
+
"@typescript-eslint/parser": "^8.72.0",
|
|
70
|
+
"@vitest/coverage-v8": "^4.1.12",
|
|
71
|
+
"eslint": "^10.13.0",
|
|
72
|
+
"globals": "^17.13.0",
|
|
73
|
+
"prettier": "^3.9.10",
|
|
74
|
+
"publint": "^0.3.25",
|
|
75
|
+
"tsup": "^8.5.1",
|
|
76
|
+
"typescript": "~5.9",
|
|
77
|
+
"typescript-eslint": "^8.72.0",
|
|
78
|
+
"vitest": "^4.1.12"
|
|
79
|
+
},
|
|
80
|
+
"sideEffects": false
|
|
81
|
+
}
|