eslint-plugin-webhook-safety 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +20 -0
- package/LICENSE +21 -0
- package/README.md +85 -2
- package/dist/index.cjs +574 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +27 -0
- package/dist/index.d.ts +27 -0
- package/dist/index.js +572 -0
- package/dist/index.js.map +1 -0
- package/docs/rules/bullmq-require-job-id.md +40 -0
- package/docs/rules/require-idempotency.md +63 -0
- package/docs/rules/require-raw-body.md +64 -0
- package/docs/rules/require-signature-verification.md +52 -0
- package/package.json +79 -4
package/dist/index.d.cts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { Rule, Linter } from 'eslint';
|
|
2
|
+
|
|
3
|
+
declare const plugin: {
|
|
4
|
+
meta: {
|
|
5
|
+
name: string;
|
|
6
|
+
version: string;
|
|
7
|
+
};
|
|
8
|
+
rules: Record<string, Rule.RuleModule>;
|
|
9
|
+
configs: {
|
|
10
|
+
recommended: Linter.Config & {
|
|
11
|
+
rules: Linter.RulesRecord;
|
|
12
|
+
};
|
|
13
|
+
strict: Linter.Config & {
|
|
14
|
+
rules: Linter.RulesRecord;
|
|
15
|
+
};
|
|
16
|
+
"legacy-recommended": {
|
|
17
|
+
plugins: string[];
|
|
18
|
+
rules: Linter.RulesRecord;
|
|
19
|
+
};
|
|
20
|
+
"legacy-strict": {
|
|
21
|
+
plugins: string[];
|
|
22
|
+
rules: Linter.RulesRecord;
|
|
23
|
+
};
|
|
24
|
+
};
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export = plugin;
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { Rule, Linter } from 'eslint';
|
|
2
|
+
|
|
3
|
+
declare const plugin: {
|
|
4
|
+
meta: {
|
|
5
|
+
name: string;
|
|
6
|
+
version: string;
|
|
7
|
+
};
|
|
8
|
+
rules: Record<string, Rule.RuleModule>;
|
|
9
|
+
configs: {
|
|
10
|
+
recommended: Linter.Config & {
|
|
11
|
+
rules: Linter.RulesRecord;
|
|
12
|
+
};
|
|
13
|
+
strict: Linter.Config & {
|
|
14
|
+
rules: Linter.RulesRecord;
|
|
15
|
+
};
|
|
16
|
+
"legacy-recommended": {
|
|
17
|
+
plugins: string[];
|
|
18
|
+
rules: Linter.RulesRecord;
|
|
19
|
+
};
|
|
20
|
+
"legacy-strict": {
|
|
21
|
+
plugins: string[];
|
|
22
|
+
rules: Linter.RulesRecord;
|
|
23
|
+
};
|
|
24
|
+
};
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export { plugin as default };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,572 @@
|
|
|
1
|
+
// src/utils.ts
|
|
2
|
+
var REPO = "https://github.com/Krrishkumar125/eslint-plugin-webhook-safety";
|
|
3
|
+
var docsUrl = (name) => `${REPO}/blob/main/docs/rules/${name}.md`;
|
|
4
|
+
var WEBHOOK_PATH = /webhook|\/hooks?(?:\/|$)/i;
|
|
5
|
+
var CONSTRUCT_EVENT = /* @__PURE__ */ new Set(["constructEvent", "constructEventAsync"]);
|
|
6
|
+
var ROUTE_METHODS = /* @__PURE__ */ new Set(["post", "put", "all"]);
|
|
7
|
+
function isFunction(node) {
|
|
8
|
+
return !!node && (node.type === "FunctionDeclaration" || node.type === "FunctionExpression" || node.type === "ArrowFunctionExpression");
|
|
9
|
+
}
|
|
10
|
+
function walk(root, visit) {
|
|
11
|
+
const stack = [root];
|
|
12
|
+
while (stack.length) {
|
|
13
|
+
const node = stack.pop();
|
|
14
|
+
visit(node);
|
|
15
|
+
for (const key of Object.keys(node)) {
|
|
16
|
+
if (key === "parent") continue;
|
|
17
|
+
const value = node[key];
|
|
18
|
+
const children = Array.isArray(value) ? value : [value];
|
|
19
|
+
for (const child of children) {
|
|
20
|
+
if (child && typeof child === "object" && typeof child.type === "string") {
|
|
21
|
+
stack.push(child);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
function calleeName(node) {
|
|
28
|
+
const callee = node.callee;
|
|
29
|
+
if (callee.type === "Identifier") return callee.name;
|
|
30
|
+
if (callee.type === "MemberExpression" && !callee.computed && callee.property.type === "Identifier") {
|
|
31
|
+
return callee.property.name;
|
|
32
|
+
}
|
|
33
|
+
return void 0;
|
|
34
|
+
}
|
|
35
|
+
function memberCall(node) {
|
|
36
|
+
if (node.type !== "CallExpression") return void 0;
|
|
37
|
+
const callee = node.callee;
|
|
38
|
+
if (callee.type === "MemberExpression" && !callee.computed && callee.object.type === "Identifier" && callee.property.type === "Identifier") {
|
|
39
|
+
return [callee.object.name, callee.property.name];
|
|
40
|
+
}
|
|
41
|
+
return void 0;
|
|
42
|
+
}
|
|
43
|
+
var PARSER_OWNERS = /* @__PURE__ */ new Set(["express", "bodyParser", "bodyparser"]);
|
|
44
|
+
function isJsonParser(node) {
|
|
45
|
+
const m = memberCall(node);
|
|
46
|
+
return !!m && PARSER_OWNERS.has(m[0]) && m[1] === "json";
|
|
47
|
+
}
|
|
48
|
+
function isRawParser(node) {
|
|
49
|
+
const m = memberCall(node);
|
|
50
|
+
return !!m && PARSER_OWNERS.has(m[0]) && (m[1] === "raw" || m[1] === "text");
|
|
51
|
+
}
|
|
52
|
+
function isBodyParser(node) {
|
|
53
|
+
const m = memberCall(node);
|
|
54
|
+
return !!m && PARSER_OWNERS.has(m[0]) && ["json", "raw", "text", "urlencoded"].includes(m[1]);
|
|
55
|
+
}
|
|
56
|
+
function isConstructEvent(node) {
|
|
57
|
+
if (node.type !== "CallExpression") return false;
|
|
58
|
+
const name = calleeName(node);
|
|
59
|
+
return !!name && CONSTRUCT_EVENT.has(name);
|
|
60
|
+
}
|
|
61
|
+
function findAll(root, test) {
|
|
62
|
+
const out = [];
|
|
63
|
+
walk(root, (n) => {
|
|
64
|
+
if (test(n)) out.push(n);
|
|
65
|
+
});
|
|
66
|
+
return out;
|
|
67
|
+
}
|
|
68
|
+
function enclosingFunction(node) {
|
|
69
|
+
let cur = node.parent;
|
|
70
|
+
while (cur) {
|
|
71
|
+
if (isFunction(cur)) return cur;
|
|
72
|
+
cur = cur.parent;
|
|
73
|
+
}
|
|
74
|
+
return void 0;
|
|
75
|
+
}
|
|
76
|
+
function literalString(node) {
|
|
77
|
+
if (!node) return void 0;
|
|
78
|
+
if (node.type === "Literal" && typeof node.value === "string") return node.value;
|
|
79
|
+
if (node.type === "TemplateLiteral" && node.expressions.length === 0) {
|
|
80
|
+
return node.quasis[0]?.value.cooked ?? void 0;
|
|
81
|
+
}
|
|
82
|
+
return void 0;
|
|
83
|
+
}
|
|
84
|
+
function asRoute(node) {
|
|
85
|
+
if (node.type !== "CallExpression") return void 0;
|
|
86
|
+
const callee = node.callee;
|
|
87
|
+
if (callee.type !== "MemberExpression" || callee.computed || callee.property.type !== "Identifier") {
|
|
88
|
+
return void 0;
|
|
89
|
+
}
|
|
90
|
+
if (!ROUTE_METHODS.has(callee.property.name)) return void 0;
|
|
91
|
+
const args = node.arguments;
|
|
92
|
+
const path = literalString(args[0]);
|
|
93
|
+
const handler = args[args.length - 1];
|
|
94
|
+
if (path === void 0 || args.length < 2 || !isFunction(handler)) return void 0;
|
|
95
|
+
return {
|
|
96
|
+
call: node,
|
|
97
|
+
owner: callee.object.type === "Identifier" ? callee.object.name : void 0,
|
|
98
|
+
path,
|
|
99
|
+
middlewares: args.slice(1, -1),
|
|
100
|
+
handler
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
function isNextPostHandler(fn) {
|
|
104
|
+
const parent = fn.parent;
|
|
105
|
+
if (fn.type === "FunctionDeclaration") {
|
|
106
|
+
return fn.id?.name === "POST" && parent?.type === "ExportNamedDeclaration";
|
|
107
|
+
}
|
|
108
|
+
if (parent?.type === "VariableDeclarator") {
|
|
109
|
+
return parent.id.type === "Identifier" && parent.id.name === "POST" && parent.parent?.parent?.type === "ExportNamedDeclaration";
|
|
110
|
+
}
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
113
|
+
function isWebhookFile(context) {
|
|
114
|
+
return WEBHOOK_PATH.test(context.filename.replace(/\\/g, "/"));
|
|
115
|
+
}
|
|
116
|
+
function eventBinding(call) {
|
|
117
|
+
let cur = call.parent;
|
|
118
|
+
if (cur?.type === "AwaitExpression") cur = cur.parent;
|
|
119
|
+
let target;
|
|
120
|
+
if (cur?.type === "VariableDeclarator") target = cur.id;
|
|
121
|
+
else if (cur?.type === "AssignmentExpression") target = cur.left;
|
|
122
|
+
if (!target) return void 0;
|
|
123
|
+
if (target.type === "Identifier") return { kind: "identifier", name: target.name };
|
|
124
|
+
if (target.type === "ObjectPattern") {
|
|
125
|
+
const hasId = target.properties.some(
|
|
126
|
+
(p) => p.type === "RestElement" || p.key.type === "Identifier" && p.key.name === "id" || p.key.type === "Literal" && p.key.value === "id"
|
|
127
|
+
);
|
|
128
|
+
return { kind: "pattern", hasId };
|
|
129
|
+
}
|
|
130
|
+
return void 0;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// src/rules/bullmq-require-job-id.ts
|
|
134
|
+
var QUEUE_NAME = /queue$/i;
|
|
135
|
+
var rule = {
|
|
136
|
+
meta: {
|
|
137
|
+
type: "problem",
|
|
138
|
+
hasSuggestions: true,
|
|
139
|
+
docs: {
|
|
140
|
+
description: "Require a jobId (or deduplication id) when a webhook handler adds a BullMQ job, so retried deliveries do not enqueue duplicates",
|
|
141
|
+
recommended: true,
|
|
142
|
+
url: docsUrl("bullmq-require-job-id")
|
|
143
|
+
},
|
|
144
|
+
schema: [],
|
|
145
|
+
messages: {
|
|
146
|
+
missing: "Webhook deliveries are retried, so this queue.add() will enqueue the same work twice. Pass a stable jobId such as the event id.",
|
|
147
|
+
addJobId: "Add { jobId: {{name}}.id }"
|
|
148
|
+
}
|
|
149
|
+
},
|
|
150
|
+
create(context) {
|
|
151
|
+
const webhookFile = isWebhookFile(context);
|
|
152
|
+
const queues = /* @__PURE__ */ new Set();
|
|
153
|
+
function isWebhookFunction(fn) {
|
|
154
|
+
const parent = fn.parent;
|
|
155
|
+
if (parent) {
|
|
156
|
+
const route = asRoute(parent);
|
|
157
|
+
if (route && route.handler === fn && WEBHOOK_PATH.test(route.path)) return true;
|
|
158
|
+
}
|
|
159
|
+
if (webhookFile && isNextPostHandler(fn)) return true;
|
|
160
|
+
return findAll(fn, isConstructEvent).some((c) => enclosingFunction(c) === fn);
|
|
161
|
+
}
|
|
162
|
+
function eventName(fn) {
|
|
163
|
+
for (const c of findAll(fn, isConstructEvent)) {
|
|
164
|
+
const b = eventBinding(c);
|
|
165
|
+
if (b?.kind === "identifier") return b.name;
|
|
166
|
+
}
|
|
167
|
+
return void 0;
|
|
168
|
+
}
|
|
169
|
+
function isQueue(obj) {
|
|
170
|
+
if (obj.type === "Identifier") return queues.has(obj.name) || QUEUE_NAME.test(obj.name);
|
|
171
|
+
return obj.type === "MemberExpression" && !obj.computed && obj.property.type === "Identifier" && QUEUE_NAME.test(obj.property.name);
|
|
172
|
+
}
|
|
173
|
+
return {
|
|
174
|
+
VariableDeclarator(node) {
|
|
175
|
+
if (node.id.type === "Identifier" && node.init?.type === "NewExpression" && node.init.callee.type === "Identifier" && node.init.callee.name === "Queue") {
|
|
176
|
+
queues.add(node.id.name);
|
|
177
|
+
}
|
|
178
|
+
},
|
|
179
|
+
CallExpression(node) {
|
|
180
|
+
const callee = node.callee;
|
|
181
|
+
if (callee.type !== "MemberExpression" || callee.computed || callee.property.type !== "Identifier" || callee.property.name !== "add" || node.arguments.length < 2 || node.arguments.length > 3 || !isQueue(callee.object)) {
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
const opts = node.arguments[2];
|
|
185
|
+
if (opts && opts.type !== "ObjectExpression") return;
|
|
186
|
+
if (opts?.type === "ObjectExpression" && opts.properties.some(
|
|
187
|
+
(p) => p.type === "SpreadElement" || p.key.type === "Identifier" && ["jobId", "deduplication"].includes(p.key.name)
|
|
188
|
+
)) {
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
let fn = enclosingFunction(node);
|
|
192
|
+
let webhookFn;
|
|
193
|
+
while (fn) {
|
|
194
|
+
if (isWebhookFunction(fn)) {
|
|
195
|
+
webhookFn = fn;
|
|
196
|
+
break;
|
|
197
|
+
}
|
|
198
|
+
fn = enclosingFunction(fn);
|
|
199
|
+
}
|
|
200
|
+
if (!webhookFn) return;
|
|
201
|
+
const name = eventName(webhookFn);
|
|
202
|
+
const suggest = [];
|
|
203
|
+
if (name) {
|
|
204
|
+
const prop = `jobId: ${name}.id`;
|
|
205
|
+
suggest.push({
|
|
206
|
+
messageId: "addJobId",
|
|
207
|
+
data: { name },
|
|
208
|
+
fix: (fixer) => {
|
|
209
|
+
if (!opts)
|
|
210
|
+
return fixer.insertTextAfter(node.arguments[1], `, { ${prop} }`);
|
|
211
|
+
const obj = opts;
|
|
212
|
+
const last = obj.properties[obj.properties.length - 1];
|
|
213
|
+
return last ? fixer.insertTextAfter(last, `, ${prop}`) : fixer.replaceText(obj, `{ ${prop} }`);
|
|
214
|
+
}
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
context.report({ node, messageId: "missing", suggest });
|
|
218
|
+
}
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
};
|
|
222
|
+
var bullmq_require_job_id_default = rule;
|
|
223
|
+
|
|
224
|
+
// src/rules/require-idempotency.ts
|
|
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
|
+
}
|
|
270
|
+
var rule2 = {
|
|
271
|
+
meta: {
|
|
272
|
+
type: "suggestion",
|
|
273
|
+
docs: {
|
|
274
|
+
description: "Suggest a duplicate-event check (using event.id) before a webhook handler performs side effects directly",
|
|
275
|
+
recommended: true,
|
|
276
|
+
url: docsUrl("require-idempotency")
|
|
277
|
+
},
|
|
278
|
+
schema: [
|
|
279
|
+
{
|
|
280
|
+
type: "object",
|
|
281
|
+
properties: {
|
|
282
|
+
idempotencyFunctions: { type: "array", items: { type: "string" } },
|
|
283
|
+
strict: { type: "boolean" }
|
|
284
|
+
},
|
|
285
|
+
additionalProperties: false
|
|
286
|
+
}
|
|
287
|
+
],
|
|
288
|
+
messages: {
|
|
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}}"
|
|
290
|
+
}
|
|
291
|
+
},
|
|
292
|
+
create(context) {
|
|
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
|
+
}
|
|
306
|
+
return {
|
|
307
|
+
CallExpression(node) {
|
|
308
|
+
if (!isConstructEvent(node)) return;
|
|
309
|
+
const binding = eventBinding(node);
|
|
310
|
+
if (!binding) return;
|
|
311
|
+
if (binding.kind === "pattern" && binding.hasId) return;
|
|
312
|
+
const name = binding.kind === "identifier" ? binding.name : "event";
|
|
313
|
+
const fn = enclosingFunction(node);
|
|
314
|
+
if (!fn) return;
|
|
315
|
+
const after = node.range?.[1] ?? 0;
|
|
316
|
+
let handled = false;
|
|
317
|
+
let firstEffect;
|
|
318
|
+
walk(fn, (n) => {
|
|
319
|
+
if (handled) return;
|
|
320
|
+
if (n.type === "MemberExpression" && n.object.type === "Identifier" && n.object.name === name && !n.computed && n.property.type === "Identifier" && n.property.name === "id") {
|
|
321
|
+
handled = true;
|
|
322
|
+
} else if (n.type === "ReturnStatement" && n.argument?.type === "Identifier" && n.argument.name === name || n.type === "ArrowFunctionExpression" && n.body.type === "Identifier" && n.body.name === name) {
|
|
323
|
+
handled = true;
|
|
324
|
+
} else if (n.type === "CallExpression") {
|
|
325
|
+
const callee = calleeName(n);
|
|
326
|
+
if (callee && (helpers.has(callee) || DEFAULT_HELPERS.test(callee))) handled = true;
|
|
327
|
+
const isConsole = n.callee.type === "MemberExpression" && n.callee.object.type === "Identifier" && n.callee.object.name === "console";
|
|
328
|
+
if (!isConsole && n.arguments.some((a) => a.type === "Identifier" && a.name === name)) {
|
|
329
|
+
handled = true;
|
|
330
|
+
}
|
|
331
|
+
if ((n.range?.[0] ?? 0) >= after && isSideEffect(n) && (!firstEffect || (n.range?.[0] ?? 0) < (firstEffect.range?.[0] ?? 0))) {
|
|
332
|
+
firstEffect = n;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
});
|
|
336
|
+
if (!handled && firstEffect) {
|
|
337
|
+
context.report({ node: firstEffect, messageId: "missing", data: { name, url } });
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
};
|
|
343
|
+
var require_idempotency_default = rule2;
|
|
344
|
+
|
|
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
|
+
}
|
|
359
|
+
var rule3 = {
|
|
360
|
+
meta: {
|
|
361
|
+
type: "problem",
|
|
362
|
+
hasSuggestions: true,
|
|
363
|
+
docs: {
|
|
364
|
+
description: "Require the raw request body for Stripe signature verification (no JSON parsing before constructEvent)",
|
|
365
|
+
recommended: true,
|
|
366
|
+
url: docsUrl("require-raw-body")
|
|
367
|
+
},
|
|
368
|
+
schema: [],
|
|
369
|
+
messages: {
|
|
370
|
+
jsonMiddleware: "express.json() parses the body before the Stripe signature check, so constructEvent will always fail. Use express.raw() on this route.",
|
|
371
|
+
globalJson: "`{{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().",
|
|
372
|
+
parsedJson: "The body is parsed with .json() before the Stripe signature check. Read the raw body with .text() and pass it to constructEvent.",
|
|
373
|
+
stringified: "JSON.stringify() of a parsed body is not byte-identical to what Stripe signed. Pass the raw body string or Buffer.",
|
|
374
|
+
useRaw: "Replace with express.raw({ type: 'application/json' })",
|
|
375
|
+
useText: "Read the body with .text() instead"
|
|
376
|
+
}
|
|
377
|
+
},
|
|
378
|
+
create(context) {
|
|
379
|
+
const globalParsers = [];
|
|
380
|
+
function handlerVerifies(fn) {
|
|
381
|
+
return findAll(fn, isConstructEvent).length > 0;
|
|
382
|
+
}
|
|
383
|
+
return {
|
|
384
|
+
CallExpression(node) {
|
|
385
|
+
const n = node;
|
|
386
|
+
const callee = n.callee;
|
|
387
|
+
if (callee.type === "MemberExpression" && !callee.computed && callee.property.type === "Identifier" && callee.property.name === "use" && callee.object.type === "Identifier") {
|
|
388
|
+
const args = n.arguments;
|
|
389
|
+
const first = args[0];
|
|
390
|
+
const pathless = !(first?.type === "Literal" && first.value !== "/");
|
|
391
|
+
if (pathless && args.some(parsesAway)) {
|
|
392
|
+
globalParsers.push({ owner: callee.object.name, node: n });
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
const route = asRoute(n);
|
|
396
|
+
if (route && handlerVerifies(route.handler) && usesParsedBody(route.handler)) {
|
|
397
|
+
const inlineJson = route.middlewares.filter(parsesAway);
|
|
398
|
+
for (const mw of inlineJson) {
|
|
399
|
+
context.report({
|
|
400
|
+
node: mw,
|
|
401
|
+
messageId: "jsonMiddleware",
|
|
402
|
+
suggest: [
|
|
403
|
+
{
|
|
404
|
+
messageId: "useRaw",
|
|
405
|
+
fix: (fixer) => fixer.replaceText(mw, "express.raw({ type: 'application/json' })")
|
|
406
|
+
}
|
|
407
|
+
]
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
if (!inlineJson.length && !route.middlewares.some(isRawParser)) {
|
|
411
|
+
const earlier = globalParsers.find(
|
|
412
|
+
(p) => p.owner === route.owner && (p.node.range?.[0] ?? 0) < (n.range?.[0] ?? 0)
|
|
413
|
+
);
|
|
414
|
+
if (earlier) {
|
|
415
|
+
context.report({
|
|
416
|
+
node: n,
|
|
417
|
+
messageId: "globalJson",
|
|
418
|
+
data: { owner: earlier.owner, line: String(earlier.node.loc?.start.line) }
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
if (isConstructEvent(n)) {
|
|
424
|
+
const payload = n.arguments[0];
|
|
425
|
+
if (payload?.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") {
|
|
426
|
+
context.report({ node: payload, messageId: "stringified" });
|
|
427
|
+
}
|
|
428
|
+
const fn = enclosingFunction(n);
|
|
429
|
+
if (!fn) return;
|
|
430
|
+
const params = new Set(
|
|
431
|
+
fn.params.flatMap((p) => p.type === "Identifier" ? [p.name] : [])
|
|
432
|
+
);
|
|
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";
|
|
434
|
+
walkSameFunction(fn, (c) => {
|
|
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) && (c.range?.[0] ?? 0) < (n.range?.[0] ?? 0)) {
|
|
436
|
+
const prop = c.callee.property;
|
|
437
|
+
context.report({
|
|
438
|
+
node: c,
|
|
439
|
+
messageId: "parsedJson",
|
|
440
|
+
suggest: [
|
|
441
|
+
{ messageId: "useText", fix: (fixer) => fixer.replaceText(prop, "text") }
|
|
442
|
+
]
|
|
443
|
+
});
|
|
444
|
+
}
|
|
445
|
+
});
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
};
|
|
449
|
+
}
|
|
450
|
+
};
|
|
451
|
+
function walkSameFunction(fn, visit) {
|
|
452
|
+
const body = fn.body;
|
|
453
|
+
const stack = [body];
|
|
454
|
+
while (stack.length) {
|
|
455
|
+
const node = stack.pop();
|
|
456
|
+
visit(node);
|
|
457
|
+
for (const key of Object.keys(node)) {
|
|
458
|
+
if (key === "parent") continue;
|
|
459
|
+
const value = node[key];
|
|
460
|
+
for (const child of Array.isArray(value) ? value : [value]) {
|
|
461
|
+
if (child && typeof child === "object" && typeof child.type === "string") {
|
|
462
|
+
if (!isFunction(child)) stack.push(child);
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
var require_raw_body_default = rule3;
|
|
469
|
+
|
|
470
|
+
// src/rules/require-signature-verification.ts
|
|
471
|
+
var DEFAULT_VERIFY = /verif|signature|constructEvent|timingSafeEqual|unwrap/i;
|
|
472
|
+
var AUTH_CALL = /auth|session|apiKey|permission|currentUser|requireUser/i;
|
|
473
|
+
var rule4 = {
|
|
474
|
+
meta: {
|
|
475
|
+
type: "problem",
|
|
476
|
+
docs: {
|
|
477
|
+
description: "Require webhook handlers to verify the request signature",
|
|
478
|
+
recommended: true,
|
|
479
|
+
url: docsUrl("require-signature-verification")
|
|
480
|
+
},
|
|
481
|
+
schema: [
|
|
482
|
+
{
|
|
483
|
+
type: "object",
|
|
484
|
+
properties: { verifyFunctions: { type: "array", items: { type: "string" } } },
|
|
485
|
+
additionalProperties: false
|
|
486
|
+
}
|
|
487
|
+
],
|
|
488
|
+
messages: {
|
|
489
|
+
missing: "This webhook handler never verifies the request signature, so anyone can forge events. Call stripe.webhooks.constructEvent() (or your provider\u2019s verify function) before trusting the body."
|
|
490
|
+
}
|
|
491
|
+
},
|
|
492
|
+
create(context) {
|
|
493
|
+
const extra = new Set(
|
|
494
|
+
context.options[0]?.verifyFunctions ?? []
|
|
495
|
+
);
|
|
496
|
+
const webhookFile = isWebhookFile(context);
|
|
497
|
+
function verifies(fn) {
|
|
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";
|
|
503
|
+
walk(fn, (n) => {
|
|
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
|
+
}
|
|
511
|
+
const name = calleeName(n);
|
|
512
|
+
if (name && (extra.has(name) || DEFAULT_VERIFY.test(name) || AUTH_CALL.test(name)))
|
|
513
|
+
found = true;
|
|
514
|
+
});
|
|
515
|
+
return found;
|
|
516
|
+
}
|
|
517
|
+
function check(fn) {
|
|
518
|
+
if (!verifies(fn)) context.report({ node: fn, messageId: "missing" });
|
|
519
|
+
}
|
|
520
|
+
return {
|
|
521
|
+
CallExpression(node) {
|
|
522
|
+
const route = asRoute(node);
|
|
523
|
+
if (!route || !WEBHOOK_PATH.test(route.path)) return;
|
|
524
|
+
if (route.middlewares.some((m) => !isBodyParser(m))) return;
|
|
525
|
+
check(route.handler);
|
|
526
|
+
},
|
|
527
|
+
"FunctionDeclaration, FunctionExpression, ArrowFunctionExpression"(node) {
|
|
528
|
+
if (webhookFile && isNextPostHandler(node)) check(node);
|
|
529
|
+
}
|
|
530
|
+
};
|
|
531
|
+
}
|
|
532
|
+
};
|
|
533
|
+
var require_signature_verification_default = rule4;
|
|
534
|
+
|
|
535
|
+
// src/index.ts
|
|
536
|
+
var rules = {
|
|
537
|
+
"require-raw-body": require_raw_body_default,
|
|
538
|
+
"require-signature-verification": require_signature_verification_default,
|
|
539
|
+
"require-idempotency": require_idempotency_default,
|
|
540
|
+
"bullmq-require-job-id": bullmq_require_job_id_default
|
|
541
|
+
};
|
|
542
|
+
var recommendedRules = {
|
|
543
|
+
"webhook-safety/require-raw-body": "error",
|
|
544
|
+
"webhook-safety/require-signature-verification": "error",
|
|
545
|
+
"webhook-safety/require-idempotency": "warn",
|
|
546
|
+
"webhook-safety/bullmq-require-job-id": "warn"
|
|
547
|
+
};
|
|
548
|
+
var strictRules = Object.fromEntries(
|
|
549
|
+
Object.keys(recommendedRules).map((k) => [k, "error"])
|
|
550
|
+
);
|
|
551
|
+
var plugin = {
|
|
552
|
+
meta: { name: "eslint-plugin-webhook-safety", version: "0.2.0" },
|
|
553
|
+
rules,
|
|
554
|
+
configs: {}
|
|
555
|
+
};
|
|
556
|
+
plugin.configs.recommended = {
|
|
557
|
+
name: "webhook-safety/recommended",
|
|
558
|
+
plugins: { "webhook-safety": plugin },
|
|
559
|
+
rules: recommendedRules
|
|
560
|
+
};
|
|
561
|
+
plugin.configs.strict = {
|
|
562
|
+
name: "webhook-safety/strict",
|
|
563
|
+
plugins: { "webhook-safety": plugin },
|
|
564
|
+
rules: strictRules
|
|
565
|
+
};
|
|
566
|
+
plugin.configs["legacy-recommended"] = { plugins: ["webhook-safety"], rules: recommendedRules };
|
|
567
|
+
plugin.configs["legacy-strict"] = { plugins: ["webhook-safety"], rules: strictRules };
|
|
568
|
+
var index_default = plugin;
|
|
569
|
+
export {
|
|
570
|
+
index_default as default
|
|
571
|
+
};
|
|
572
|
+
//# sourceMappingURL=index.js.map
|