eslint-plugin-webhook-safety 0.1.0 → 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 CHANGED
@@ -1,5 +1,16 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.0 (2026-10-22)
4
+
5
+ - `require-idempotency` is now conservative: it reports only **direct** writes in the handler (DB, queue, email,
6
+ payment calls) with no `event.id` use, and points at that write. Helper calls that receive event-derived data
7
+ are treated as possibly idempotent (false positive found on twentyhq/twenty). New `strict` option restores
8
+ the broader check. The message now reads as a suggestion and links the docs.
9
+ - `require-signature-verification` no longer reports handlers that pass the request to a helper (verification in another file).
10
+ - `require-raw-body` ignores `express.json({ verify })` and handlers that verify something other than `req.body` (e.g. `req.rawBody`).
11
+ - README: correct example output and a TypeScript setup section.
12
+ - Release smoke test now covers ESLint 8.57 (flat and legacy), 9, 10 and `@typescript-eslint/parser`. CI adds Windows.
13
+
3
14
  ## 0.1.0 (2026-10-22)
4
15
 
5
16
  - Rules: `require-raw-body`, `require-signature-verification`, `require-idempotency`, `bullmq-require-job-id`.
package/README.md CHANGED
@@ -38,9 +38,9 @@ app.post('/webhooks/stripe', async (req, res) => {
38
38
 
39
39
  ```text
40
40
  $ npx eslint server.js
41
- 3:1 error `app.use(express.json())` on line 1 runs before this webhook route and parses the body ... webhook-safety/require-raw-body
42
- 4:17 warning Stripe retries and duplicates webhook deliveries, but this handler never uses `event.id` ... webhook-safety/require-idempotency
43
- 5:9 warning Webhook deliveries are retried, so this queue.add() will enqueue the same work twice ... webhook-safety/bullmq-require-job-id
41
+ 2:1 error `app.use(express.json())` on line 1 runs before this webhook route and parses the body ... webhook-safety/require-raw-body
42
+ 4:9 warning Consider a duplicate-event check: Stripe can deliver `event` more than once ... webhook-safety/require-idempotency
43
+ 4:9 warning Webhook deliveries are retried, so this queue.add() will enqueue the same work twice ... webhook-safety/bullmq-require-job-id
44
44
  ```
45
45
 
46
46
  ## Rules
@@ -49,14 +49,26 @@ $ npx eslint server.js
49
49
  | ------------------------------------------------------------------------------ | -------------------------------------------------------------------------- | ----------- | ------ | --- |
50
50
  | [require-raw-body](docs/rules/require-raw-body.md) | `express.json()` / `req.json()` / `JSON.stringify` before `constructEvent` | error | error | ✅ |
51
51
  | [require-signature-verification](docs/rules/require-signature-verification.md) | webhook route or Next.js `POST` with no signature check | error | error | |
52
- | [require-idempotency](docs/rules/require-idempotency.md) | `constructEvent` result whose `event.id` is never used for dedupe | warn | error | |
52
+ | [require-idempotency](docs/rules/require-idempotency.md) | DB/queue/email/payment write in the handler with no `event.id` dedupe | warn | error | |
53
53
  | [bullmq-require-job-id](docs/rules/bullmq-require-job-id.md) | `queue.add()` in a webhook handler without `jobId` | warn | error | ✅ |
54
54
 
55
55
  The rules are tuned for **low false positives**. When in doubt (custom middleware, the event passed to a
56
56
  helper, options passed as a variable) they stay quiet. Each rule doc lists exactly what triggers it.
57
57
 
58
- Supports Express, Hono, Fastify-style routers and the Next.js App Router, in JavaScript and TypeScript
59
- (use `typescript-eslint`'s parser as usual).
58
+ Supports Express, Hono, Fastify-style routers and the Next.js App Router, in JavaScript and TypeScript.
59
+
60
+ ### TypeScript
61
+
62
+ ESLint only lints `.ts` files that some config object matches, so pair the plugin with
63
+ `typescript-eslint` (or at least its parser):
64
+
65
+ ```js
66
+ // eslint.config.js
67
+ import tseslint from 'typescript-eslint';
68
+ import webhookSafety from 'eslint-plugin-webhook-safety';
69
+
70
+ export default [...tseslint.configs.recommended, webhookSafety.configs.recommended];
71
+ ```
60
72
 
61
73
  ### Legacy `.eslintrc` (ESLint 8)
62
74
 
@@ -66,7 +78,8 @@ Supports Express, Hono, Fastify-style routers and the Next.js App Router, in Jav
66
78
 
67
79
  ## Requirements
68
80
 
69
- ESLint ≥ 8.57 (flat config) and Node ≥ 20.
81
+ ESLint 8.57+, 9 or 10 (flat config, or `.eslintrc` via the legacy configs) and Node ≥ 20. Every release is
82
+ smoke-tested from the packed tarball against ESLint 8.57 (flat and legacy), 9, 10 and `@typescript-eslint/parser`.
70
83
 
71
84
  ## License
72
85
 
package/dist/index.cjs CHANGED
@@ -223,48 +223,103 @@ var bullmq_require_job_id_default = rule;
223
223
 
224
224
  // src/rules/require-idempotency.ts
225
225
  var DEFAULT_HELPERS = /idempot|dedup|processed|alreadyHandled|seenEvent/i;
226
+ var SIDE_EFFECT_METHODS = /* @__PURE__ */ new Set([
227
+ "create",
228
+ "createMany",
229
+ "insert",
230
+ "insertOne",
231
+ "insertMany",
232
+ "update",
233
+ "updateOne",
234
+ "updateMany",
235
+ "upsert",
236
+ "delete",
237
+ "deleteMany",
238
+ "increment",
239
+ "decrement",
240
+ "save",
241
+ "add",
242
+ "addBulk",
243
+ "enqueue",
244
+ "publish",
245
+ "send",
246
+ "sendMail",
247
+ "sendEmail",
248
+ "charge",
249
+ "capture",
250
+ "refund",
251
+ "transfer"
252
+ ]);
253
+ var NON_EFFECT_OBJECTS = /* @__PURE__ */ new Set([
254
+ "console",
255
+ "Response",
256
+ "NextResponse",
257
+ "JSON",
258
+ "res",
259
+ "Math",
260
+ "Object",
261
+ "Array"
262
+ ]);
263
+ function rootObject(node) {
264
+ let cur = node;
265
+ while (cur.type === "MemberExpression" || cur.type === "CallExpression") {
266
+ cur = cur.type === "MemberExpression" ? cur.object : cur.callee;
267
+ }
268
+ return cur;
269
+ }
226
270
  var rule2 = {
227
271
  meta: {
228
272
  type: "suggestion",
229
273
  docs: {
230
- description: "Require a duplicate-event check (using event.id) before a webhook handler performs side effects",
274
+ description: "Suggest a duplicate-event check (using event.id) before a webhook handler performs side effects directly",
231
275
  recommended: true,
232
276
  url: docsUrl("require-idempotency")
233
277
  },
234
278
  schema: [
235
279
  {
236
280
  type: "object",
237
- properties: { idempotencyFunctions: { type: "array", items: { type: "string" } } },
281
+ properties: {
282
+ idempotencyFunctions: { type: "array", items: { type: "string" } },
283
+ strict: { type: "boolean" }
284
+ },
238
285
  additionalProperties: false
239
286
  }
240
287
  ],
241
288
  messages: {
242
- missing: "Stripe retries and duplicates webhook deliveries, but this handler never uses `{{name}}.id`. Record processed event ids (unique insert) and skip duplicates before side effects."
289
+ missing: "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}}"
243
290
  }
244
291
  },
245
292
  create(context) {
246
- const helpers = new Set(
247
- _nullishCoalesce(_optionalChain([context, 'access', _19 => _19.options, 'access', _20 => _20[0], 'optionalAccess', _21 => _21.idempotencyFunctions]), () => ( []))
248
- );
293
+ const options = _nullishCoalesce(context.options[0], () => ( {}));
294
+ const helpers = new Set(_nullishCoalesce(options.idempotencyFunctions, () => ( [])));
295
+ const url = docsUrl("require-idempotency");
296
+ function isSideEffect(call) {
297
+ const callee = call.callee;
298
+ if (callee.type === "MemberExpression") {
299
+ const root = rootObject(callee);
300
+ if (root.type === "Identifier" && NON_EFFECT_OBJECTS.has(root.name)) return false;
301
+ const name = calleeName(call);
302
+ if (name && SIDE_EFFECT_METHODS.has(name)) return true;
303
+ }
304
+ return !!options.strict && !isConstructEvent(call) && call.arguments.length > 0;
305
+ }
249
306
  return {
250
307
  CallExpression(node) {
251
308
  if (!isConstructEvent(node)) return;
252
309
  const binding = eventBinding(node);
253
310
  if (!binding) return;
254
- if (binding.kind === "pattern") {
255
- if (!binding.hasId)
256
- context.report({ node, messageId: "missing", data: { name: "event" } });
257
- return;
258
- }
311
+ if (binding.kind === "pattern" && binding.hasId) return;
312
+ const name = binding.kind === "identifier" ? binding.name : "event";
259
313
  const fn = enclosingFunction(node);
260
314
  if (!fn) return;
261
- const name = binding.name;
315
+ const after = _nullishCoalesce(_optionalChain([node, 'access', _19 => _19.range, 'optionalAccess', _20 => _20[1]]), () => ( 0));
262
316
  let handled = false;
317
+ let firstEffect;
263
318
  walk(fn, (n) => {
264
319
  if (handled) return;
265
320
  if (n.type === "MemberExpression" && n.object.type === "Identifier" && n.object.name === name && !n.computed && n.property.type === "Identifier" && n.property.name === "id") {
266
321
  handled = true;
267
- } else if (n.type === "ReturnStatement" && _optionalChain([n, 'access', _22 => _22.argument, 'optionalAccess', _23 => _23.type]) === "Identifier" && n.argument.name === name || n.type === "ArrowFunctionExpression" && n.body.type === "Identifier" && n.body.name === name) {
322
+ } else if (n.type === "ReturnStatement" && _optionalChain([n, 'access', _21 => _21.argument, 'optionalAccess', _22 => _22.type]) === "Identifier" && n.argument.name === name || n.type === "ArrowFunctionExpression" && n.body.type === "Identifier" && n.body.name === name) {
268
323
  handled = true;
269
324
  } else if (n.type === "CallExpression") {
270
325
  const callee = calleeName(n);
@@ -273,9 +328,14 @@ var rule2 = {
273
328
  if (!isConsole && n.arguments.some((a) => a.type === "Identifier" && a.name === name)) {
274
329
  handled = true;
275
330
  }
331
+ if ((_nullishCoalesce(_optionalChain([n, 'access', _23 => _23.range, 'optionalAccess', _24 => _24[0]]), () => ( 0))) >= after && isSideEffect(n) && (!firstEffect || (_nullishCoalesce(_optionalChain([n, 'access', _25 => _25.range, 'optionalAccess', _26 => _26[0]]), () => ( 0))) < (_nullishCoalesce(_optionalChain([firstEffect, 'access', _27 => _27.range, 'optionalAccess', _28 => _28[0]]), () => ( 0))))) {
332
+ firstEffect = n;
333
+ }
276
334
  }
277
335
  });
278
- if (!handled) context.report({ node, messageId: "missing", data: { name } });
336
+ if (!handled && firstEffect) {
337
+ context.report({ node: firstEffect, messageId: "missing", data: { name, url } });
338
+ }
279
339
  }
280
340
  };
281
341
  }
@@ -283,6 +343,19 @@ var rule2 = {
283
343
  var require_idempotency_default = rule2;
284
344
 
285
345
  // src/rules/require-raw-body.ts
346
+ function parsesAway(node) {
347
+ if (!isJsonParser(node)) return false;
348
+ const opts = node.arguments[0];
349
+ return !(_optionalChain([opts, 'optionalAccess', _29 => _29.type]) === "ObjectExpression" && opts.properties.some(
350
+ (p) => p.type === "Property" && p.key.type === "Identifier" && p.key.name === "verify"
351
+ ));
352
+ }
353
+ function usesParsedBody(fn) {
354
+ return findAll(fn, isConstructEvent).some((c) => {
355
+ const payload = c.arguments[0];
356
+ return _optionalChain([payload, 'optionalAccess', _30 => _30.type]) === "MemberExpression" && !payload.computed && payload.property.type === "Identifier" && payload.property.name === "body";
357
+ });
358
+ }
286
359
  var rule3 = {
287
360
  meta: {
288
361
  type: "problem",
@@ -314,14 +387,14 @@ var rule3 = {
314
387
  if (callee.type === "MemberExpression" && !callee.computed && callee.property.type === "Identifier" && callee.property.name === "use" && callee.object.type === "Identifier") {
315
388
  const args = n.arguments;
316
389
  const first = args[0];
317
- const pathless = !(_optionalChain([first, 'optionalAccess', _24 => _24.type]) === "Literal" && first.value !== "/");
318
- if (pathless && args.some(isJsonParser)) {
390
+ const pathless = !(_optionalChain([first, 'optionalAccess', _31 => _31.type]) === "Literal" && first.value !== "/");
391
+ if (pathless && args.some(parsesAway)) {
319
392
  globalParsers.push({ owner: callee.object.name, node: n });
320
393
  }
321
394
  }
322
395
  const route = asRoute(n);
323
- if (route && handlerVerifies(route.handler)) {
324
- const inlineJson = route.middlewares.filter(isJsonParser);
396
+ if (route && handlerVerifies(route.handler) && usesParsedBody(route.handler)) {
397
+ const inlineJson = route.middlewares.filter(parsesAway);
325
398
  for (const mw of inlineJson) {
326
399
  context.report({
327
400
  node: mw,
@@ -336,20 +409,20 @@ var rule3 = {
336
409
  }
337
410
  if (!inlineJson.length && !route.middlewares.some(isRawParser)) {
338
411
  const earlier = globalParsers.find(
339
- (p) => p.owner === route.owner && (_nullishCoalesce(_optionalChain([p, 'access', _25 => _25.node, 'access', _26 => _26.range, 'optionalAccess', _27 => _27[0]]), () => ( 0))) < (_nullishCoalesce(_optionalChain([n, 'access', _28 => _28.range, 'optionalAccess', _29 => _29[0]]), () => ( 0)))
412
+ (p) => p.owner === route.owner && (_nullishCoalesce(_optionalChain([p, 'access', _32 => _32.node, 'access', _33 => _33.range, 'optionalAccess', _34 => _34[0]]), () => ( 0))) < (_nullishCoalesce(_optionalChain([n, 'access', _35 => _35.range, 'optionalAccess', _36 => _36[0]]), () => ( 0)))
340
413
  );
341
414
  if (earlier) {
342
415
  context.report({
343
416
  node: n,
344
417
  messageId: "globalJson",
345
- data: { owner: earlier.owner, line: String(_optionalChain([earlier, 'access', _30 => _30.node, 'access', _31 => _31.loc, 'optionalAccess', _32 => _32.start, 'access', _33 => _33.line])) }
418
+ data: { owner: earlier.owner, line: String(_optionalChain([earlier, 'access', _37 => _37.node, 'access', _38 => _38.loc, 'optionalAccess', _39 => _39.start, 'access', _40 => _40.line])) }
346
419
  });
347
420
  }
348
421
  }
349
422
  }
350
423
  if (isConstructEvent(n)) {
351
424
  const payload = n.arguments[0];
352
- if (_optionalChain([payload, 'optionalAccess', _34 => _34.type]) === "CallExpression" && payload.callee.type === "MemberExpression" && payload.callee.object.type === "Identifier" && payload.callee.object.name === "JSON" && payload.callee.property.type === "Identifier" && payload.callee.property.name === "stringify") {
425
+ if (_optionalChain([payload, 'optionalAccess', _41 => _41.type]) === "CallExpression" && payload.callee.type === "MemberExpression" && payload.callee.object.type === "Identifier" && payload.callee.object.name === "JSON" && payload.callee.property.type === "Identifier" && payload.callee.property.name === "stringify") {
353
426
  context.report({ node: payload, messageId: "stringified" });
354
427
  }
355
428
  const fn = enclosingFunction(n);
@@ -359,7 +432,7 @@ var rule3 = {
359
432
  );
360
433
  const isRequest = (obj) => obj.type === "Identifier" && params.has(obj.name) || obj.type === "MemberExpression" && obj.object.type === "Identifier" && params.has(obj.object.name) && obj.property.type === "Identifier" && obj.property.name === "req";
361
434
  walkSameFunction(fn, (c) => {
362
- if (c.type === "CallExpression" && c.arguments.length === 0 && c.callee.type === "MemberExpression" && !c.callee.computed && c.callee.property.type === "Identifier" && c.callee.property.name === "json" && isRequest(c.callee.object) && (_nullishCoalesce(_optionalChain([c, 'access', _35 => _35.range, 'optionalAccess', _36 => _36[0]]), () => ( 0))) < (_nullishCoalesce(_optionalChain([n, 'access', _37 => _37.range, 'optionalAccess', _38 => _38[0]]), () => ( 0)))) {
435
+ if (c.type === "CallExpression" && c.arguments.length === 0 && c.callee.type === "MemberExpression" && !c.callee.computed && c.callee.property.type === "Identifier" && c.callee.property.name === "json" && isRequest(c.callee.object) && (_nullishCoalesce(_optionalChain([c, 'access', _42 => _42.range, 'optionalAccess', _43 => _43[0]]), () => ( 0))) < (_nullishCoalesce(_optionalChain([n, 'access', _44 => _44.range, 'optionalAccess', _45 => _45[0]]), () => ( 0)))) {
363
436
  const prop = c.callee.property;
364
437
  context.report({
365
438
  node: c,
@@ -418,13 +491,23 @@ var rule4 = {
418
491
  },
419
492
  create(context) {
420
493
  const extra = new Set(
421
- _nullishCoalesce(_optionalChain([context, 'access', _39 => _39.options, 'access', _40 => _40[0], 'optionalAccess', _41 => _41.verifyFunctions]), () => ( []))
494
+ _nullishCoalesce(_optionalChain([context, 'access', _46 => _46.options, 'access', _47 => _47[0], 'optionalAccess', _48 => _48.verifyFunctions]), () => ( []))
422
495
  );
423
496
  const webhookFile = isWebhookFile(context);
424
497
  function verifies(fn) {
425
498
  let found = false;
499
+ const params = new Set(
500
+ fn.params.flatMap((p) => p.type === "Identifier" ? [p.name] : [])
501
+ );
502
+ const isRequest = (a) => a.type === "Identifier" && params.has(a.name) || a.type === "MemberExpression" && a.object.type === "Identifier" && params.has(a.object.name) && !a.computed && a.property.type === "Identifier" && a.property.name === "req";
426
503
  walk(fn, (n) => {
427
504
  if (found || n.type !== "CallExpression" && n.type !== "NewExpression") return;
505
+ const root = n.callee.type === "MemberExpression" ? n.callee.object : void 0;
506
+ const builtin = _optionalChain([root, 'optionalAccess', _49 => _49.type]) === "Identifier" && ["console", "JSON", "Response", "NextResponse", "res", "Object"].includes(root.name);
507
+ if (!builtin && n.arguments.some(isRequest)) {
508
+ found = true;
509
+ return;
510
+ }
428
511
  const name = calleeName(n);
429
512
  if (name && (extra.has(name) || DEFAULT_VERIFY.test(name) || AUTH_CALL.test(name)))
430
513
  found = true;
@@ -466,7 +549,7 @@ var strictRules = Object.fromEntries(
466
549
  Object.keys(recommendedRules).map((k) => [k, "error"])
467
550
  );
468
551
  var plugin = {
469
- meta: { name: "eslint-plugin-webhook-safety", version: "0.1.0" },
552
+ meta: { name: "eslint-plugin-webhook-safety", version: "0.2.0" },
470
553
  rules,
471
554
  configs: {}
472
555
  };
@@ -1 +1 @@
1
- {"version":3,"sources":["/workspace/eslint-plugin-webhook-safety/dist/index.cjs","../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"],"names":[],"mappings":"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","file":"/workspace/eslint-plugin-webhook-safety/dist/index.cjs","sourcesContent":[null,"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 {\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\nconst rule: Rule.RuleModule = {\n meta: {\n type: 'suggestion',\n docs: {\n description:\n 'Require a duplicate-event check (using event.id) before a webhook handler performs side effects',\n recommended: true,\n url: docsUrl('require-idempotency'),\n },\n schema: [\n {\n type: 'object',\n properties: { idempotencyFunctions: { type: 'array', items: { type: 'string' } } },\n additionalProperties: false,\n },\n ],\n messages: {\n missing:\n 'Stripe retries and duplicates webhook deliveries, but this handler never uses `{{name}}.id`. Record processed event ids (unique insert) and skip duplicates before side effects.',\n },\n },\n create(context) {\n const helpers = new Set<string>(\n (context.options[0] as { idempotencyFunctions?: string[] } | undefined)\n ?.idempotencyFunctions ?? [],\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') {\n if (!binding.hasId)\n context.report({ node, messageId: 'missing', data: { name: 'event' } });\n return;\n }\n const fn = enclosingFunction(node as Node);\n if (!fn) return;\n const name = binding.name;\n let handled = false;\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 }\n });\n if (!handled) context.report({ node, messageId: 'missing', data: { name } });\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\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(isJsonParser)) {\n globalParsers.push({ owner: callee.object.name, node: n });\n }\n }\n\n const route = asRoute(n);\n if (route && handlerVerifies(route.handler)) {\n const inlineJson = route.middlewares.filter(isJsonParser);\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 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 walk(fn, (n) => {\n if (found || (n.type !== 'CallExpression' && n.type !== 'NewExpression')) return;\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.1.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"]}
1
+ {"version":3,"sources":["/workspace/eslint-plugin-webhook-safety/dist/index.cjs","../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"],"names":[],"mappings":"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","file":"/workspace/eslint-plugin-webhook-safety/dist/index.cjs","sourcesContent":[null,"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"]}
package/dist/index.js CHANGED
@@ -223,43 +223,98 @@ var bullmq_require_job_id_default = rule;
223
223
 
224
224
  // src/rules/require-idempotency.ts
225
225
  var DEFAULT_HELPERS = /idempot|dedup|processed|alreadyHandled|seenEvent/i;
226
+ var SIDE_EFFECT_METHODS = /* @__PURE__ */ new Set([
227
+ "create",
228
+ "createMany",
229
+ "insert",
230
+ "insertOne",
231
+ "insertMany",
232
+ "update",
233
+ "updateOne",
234
+ "updateMany",
235
+ "upsert",
236
+ "delete",
237
+ "deleteMany",
238
+ "increment",
239
+ "decrement",
240
+ "save",
241
+ "add",
242
+ "addBulk",
243
+ "enqueue",
244
+ "publish",
245
+ "send",
246
+ "sendMail",
247
+ "sendEmail",
248
+ "charge",
249
+ "capture",
250
+ "refund",
251
+ "transfer"
252
+ ]);
253
+ var NON_EFFECT_OBJECTS = /* @__PURE__ */ new Set([
254
+ "console",
255
+ "Response",
256
+ "NextResponse",
257
+ "JSON",
258
+ "res",
259
+ "Math",
260
+ "Object",
261
+ "Array"
262
+ ]);
263
+ function rootObject(node) {
264
+ let cur = node;
265
+ while (cur.type === "MemberExpression" || cur.type === "CallExpression") {
266
+ cur = cur.type === "MemberExpression" ? cur.object : cur.callee;
267
+ }
268
+ return cur;
269
+ }
226
270
  var rule2 = {
227
271
  meta: {
228
272
  type: "suggestion",
229
273
  docs: {
230
- description: "Require a duplicate-event check (using event.id) before a webhook handler performs side effects",
274
+ description: "Suggest a duplicate-event check (using event.id) before a webhook handler performs side effects directly",
231
275
  recommended: true,
232
276
  url: docsUrl("require-idempotency")
233
277
  },
234
278
  schema: [
235
279
  {
236
280
  type: "object",
237
- properties: { idempotencyFunctions: { type: "array", items: { type: "string" } } },
281
+ properties: {
282
+ idempotencyFunctions: { type: "array", items: { type: "string" } },
283
+ strict: { type: "boolean" }
284
+ },
238
285
  additionalProperties: false
239
286
  }
240
287
  ],
241
288
  messages: {
242
- missing: "Stripe retries and duplicates webhook deliveries, but this handler never uses `{{name}}.id`. Record processed event ids (unique insert) and skip duplicates before side effects."
289
+ missing: "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}}"
243
290
  }
244
291
  },
245
292
  create(context) {
246
- const helpers = new Set(
247
- context.options[0]?.idempotencyFunctions ?? []
248
- );
293
+ const options = context.options[0] ?? {};
294
+ const helpers = new Set(options.idempotencyFunctions ?? []);
295
+ const url = docsUrl("require-idempotency");
296
+ function isSideEffect(call) {
297
+ const callee = call.callee;
298
+ if (callee.type === "MemberExpression") {
299
+ const root = rootObject(callee);
300
+ if (root.type === "Identifier" && NON_EFFECT_OBJECTS.has(root.name)) return false;
301
+ const name = calleeName(call);
302
+ if (name && SIDE_EFFECT_METHODS.has(name)) return true;
303
+ }
304
+ return !!options.strict && !isConstructEvent(call) && call.arguments.length > 0;
305
+ }
249
306
  return {
250
307
  CallExpression(node) {
251
308
  if (!isConstructEvent(node)) return;
252
309
  const binding = eventBinding(node);
253
310
  if (!binding) return;
254
- if (binding.kind === "pattern") {
255
- if (!binding.hasId)
256
- context.report({ node, messageId: "missing", data: { name: "event" } });
257
- return;
258
- }
311
+ if (binding.kind === "pattern" && binding.hasId) return;
312
+ const name = binding.kind === "identifier" ? binding.name : "event";
259
313
  const fn = enclosingFunction(node);
260
314
  if (!fn) return;
261
- const name = binding.name;
315
+ const after = node.range?.[1] ?? 0;
262
316
  let handled = false;
317
+ let firstEffect;
263
318
  walk(fn, (n) => {
264
319
  if (handled) return;
265
320
  if (n.type === "MemberExpression" && n.object.type === "Identifier" && n.object.name === name && !n.computed && n.property.type === "Identifier" && n.property.name === "id") {
@@ -273,9 +328,14 @@ var rule2 = {
273
328
  if (!isConsole && n.arguments.some((a) => a.type === "Identifier" && a.name === name)) {
274
329
  handled = true;
275
330
  }
331
+ if ((n.range?.[0] ?? 0) >= after && isSideEffect(n) && (!firstEffect || (n.range?.[0] ?? 0) < (firstEffect.range?.[0] ?? 0))) {
332
+ firstEffect = n;
333
+ }
276
334
  }
277
335
  });
278
- if (!handled) context.report({ node, messageId: "missing", data: { name } });
336
+ if (!handled && firstEffect) {
337
+ context.report({ node: firstEffect, messageId: "missing", data: { name, url } });
338
+ }
279
339
  }
280
340
  };
281
341
  }
@@ -283,6 +343,19 @@ var rule2 = {
283
343
  var require_idempotency_default = rule2;
284
344
 
285
345
  // src/rules/require-raw-body.ts
346
+ function parsesAway(node) {
347
+ if (!isJsonParser(node)) return false;
348
+ const opts = node.arguments[0];
349
+ return !(opts?.type === "ObjectExpression" && opts.properties.some(
350
+ (p) => p.type === "Property" && p.key.type === "Identifier" && p.key.name === "verify"
351
+ ));
352
+ }
353
+ function usesParsedBody(fn) {
354
+ return findAll(fn, isConstructEvent).some((c) => {
355
+ const payload = c.arguments[0];
356
+ return payload?.type === "MemberExpression" && !payload.computed && payload.property.type === "Identifier" && payload.property.name === "body";
357
+ });
358
+ }
286
359
  var rule3 = {
287
360
  meta: {
288
361
  type: "problem",
@@ -315,13 +388,13 @@ var rule3 = {
315
388
  const args = n.arguments;
316
389
  const first = args[0];
317
390
  const pathless = !(first?.type === "Literal" && first.value !== "/");
318
- if (pathless && args.some(isJsonParser)) {
391
+ if (pathless && args.some(parsesAway)) {
319
392
  globalParsers.push({ owner: callee.object.name, node: n });
320
393
  }
321
394
  }
322
395
  const route = asRoute(n);
323
- if (route && handlerVerifies(route.handler)) {
324
- const inlineJson = route.middlewares.filter(isJsonParser);
396
+ if (route && handlerVerifies(route.handler) && usesParsedBody(route.handler)) {
397
+ const inlineJson = route.middlewares.filter(parsesAway);
325
398
  for (const mw of inlineJson) {
326
399
  context.report({
327
400
  node: mw,
@@ -423,8 +496,18 @@ var rule4 = {
423
496
  const webhookFile = isWebhookFile(context);
424
497
  function verifies(fn) {
425
498
  let found = false;
499
+ const params = new Set(
500
+ fn.params.flatMap((p) => p.type === "Identifier" ? [p.name] : [])
501
+ );
502
+ const isRequest = (a) => a.type === "Identifier" && params.has(a.name) || a.type === "MemberExpression" && a.object.type === "Identifier" && params.has(a.object.name) && !a.computed && a.property.type === "Identifier" && a.property.name === "req";
426
503
  walk(fn, (n) => {
427
504
  if (found || n.type !== "CallExpression" && n.type !== "NewExpression") return;
505
+ const root = n.callee.type === "MemberExpression" ? n.callee.object : void 0;
506
+ const builtin = root?.type === "Identifier" && ["console", "JSON", "Response", "NextResponse", "res", "Object"].includes(root.name);
507
+ if (!builtin && n.arguments.some(isRequest)) {
508
+ found = true;
509
+ return;
510
+ }
428
511
  const name = calleeName(n);
429
512
  if (name && (extra.has(name) || DEFAULT_VERIFY.test(name) || AUTH_CALL.test(name)))
430
513
  found = true;
@@ -466,7 +549,7 @@ var strictRules = Object.fromEntries(
466
549
  Object.keys(recommendedRules).map((k) => [k, "error"])
467
550
  );
468
551
  var plugin = {
469
- meta: { name: "eslint-plugin-webhook-safety", version: "0.1.0" },
552
+ meta: { name: "eslint-plugin-webhook-safety", version: "0.2.0" },
470
553
  rules,
471
554
  configs: {}
472
555
  };
package/dist/index.js.map CHANGED
@@ -1 +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 {\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\nconst rule: Rule.RuleModule = {\n meta: {\n type: 'suggestion',\n docs: {\n description:\n 'Require a duplicate-event check (using event.id) before a webhook handler performs side effects',\n recommended: true,\n url: docsUrl('require-idempotency'),\n },\n schema: [\n {\n type: 'object',\n properties: { idempotencyFunctions: { type: 'array', items: { type: 'string' } } },\n additionalProperties: false,\n },\n ],\n messages: {\n missing:\n 'Stripe retries and duplicates webhook deliveries, but this handler never uses `{{name}}.id`. Record processed event ids (unique insert) and skip duplicates before side effects.',\n },\n },\n create(context) {\n const helpers = new Set<string>(\n (context.options[0] as { idempotencyFunctions?: string[] } | undefined)\n ?.idempotencyFunctions ?? [],\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') {\n if (!binding.hasId)\n context.report({ node, messageId: 'missing', data: { name: 'event' } });\n return;\n }\n const fn = enclosingFunction(node as Node);\n if (!fn) return;\n const name = binding.name;\n let handled = false;\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 }\n });\n if (!handled) context.report({ node, messageId: 'missing', data: { name } });\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\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(isJsonParser)) {\n globalParsers.push({ owner: callee.object.name, node: n });\n }\n }\n\n const route = asRoute(n);\n if (route && handlerVerifies(route.handler)) {\n const inlineJson = route.middlewares.filter(isJsonParser);\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 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 walk(fn, (n) => {\n if (found || (n.type !== 'CallExpression' && n.type !== 'NewExpression')) return;\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.1.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"]}
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"]}
@@ -1,6 +1,6 @@
1
1
  # require-idempotency
2
2
 
3
- Require a duplicate-event check (using `event.id`) before a webhook handler performs side effects.
3
+ Suggest a duplicate-event check (using `event.id`) when a webhook handler writes directly.
4
4
 
5
5
  💼 `recommended` (warn), `strict` (error)
6
6
 
@@ -10,24 +10,34 @@ Stripe delivers events **at least once**. It retries on timeouts and non-2xx res
10
10
  sometimes sends the same event twice. Stripe's docs say to log processed event ids and skip
11
11
  ones you have already handled. Without that, users get double credits, double emails and double refunds.
12
12
 
13
- ## What it reports
13
+ ## What ## What it reports
14
14
 
15
- A `constructEvent(...)` result that is stored in a variable (or destructured) when the enclosing
16
- function never reads `event.id`.
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.
17
19
 
18
- It stays silent when:
20
+ It is deliberately conservative and stays silent when:
19
21
 
20
22
  - the whole `event` is passed to another function (`handleStripeEvent(event)`), which may dedupe;
21
- - a call's name looks like a dedupe helper (`/idempot|dedup|processed|alreadyHandled|seenEvent/i`);
22
- - the call is listed in `idempotencyFunctions`.
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.
23
30
 
24
31
  ## Options
25
32
 
26
33
  ```js
27
- 'webhook-safety/require-idempotency': ['warn', { idempotencyFunctions: ['once'] }]
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
+ }]
28
38
  ```
29
39
 
30
- ## Examples
40
+ les
31
41
 
32
42
  ❌ Incorrect
33
43
 
@@ -21,6 +21,9 @@ single most common Stripe webhook bug (see the
21
21
  - `await req.json()` (or Hono's `c.req.json()`) before `constructEvent` in the same function.
22
22
  - `constructEvent(JSON.stringify(...), ...)`.
23
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
+
24
27
  ## Examples
25
28
 
26
29
  ❌ Incorrect
@@ -19,8 +19,11 @@ A handler that never calls anything verification-like:
19
19
  A call counts as verification when its name matches `/verif|signature|constructEvent|timingSafeEqual|unwrap/i`
20
20
  (`constructEvent`, `verifySignature`, `wh.verify`, `crypto.timingSafeEqual`, `client.webhooks.unwrap` …).
21
21
 
22
- To keep false positives low the rule stays silent when the route has any middleware other than a
23
- body parser, because that middleware may do the verification.
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.
24
27
 
25
28
  ## Options
26
29
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "eslint-plugin-webhook-safety",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
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
5
  "license": "MIT",
6
6
  "author": "Krrish Kumar (https://github.com/Krrishkumar125)",