Skip to content
SlashClone
Made by@shipcheck

Stripe webhook hardening

@shipcheck/stripe-webhook-hardening

Find your Stripe webhook handlers and harden them: verify the Stripe-Signature header, use the raw body, keep the signing secret in env, process each event once, reject bad signatures with 400, and fulfill orders from the webhook rather than the success page. Reads your code and proposes diffs; never calls Stripe.

SkillDevOpsv1.0.0 (latest)Tested
Activity
New: be the first to try it
  1. 1#!/usr/bin/env node
  2. 2// Find Stripe webhook handlers in a project and check the basics:
  3. 3// signature verified, raw body used, secret read from env, idempotent on event.id,
  4. 4// bad signatures rejected with 400, event types filtered, edge runtime handled.
  5. 5// This is a first pass that points Claude (and you) at the right lines. It reads code
  6. 6// only; it never calls Stripe. Node built-ins only.
  7. 7//
  8. 8// Usage: node scripts/webhook-scan.mjs [project-dir] [--json out/webhook-scan.json] [--md out/webhook-scan.md]
  9. 9
  10. 10import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
  11. 11import { dirname, join, relative, sep } from "node:path";
  12. 13const SKIP_DIRS = new Set([".git", "node_modules", ".next", ".nuxt", ".svelte-kit", ".vercel", ".netlify", "dist", "build", "coverage", "out", "vendor", ".venv", "venv", "__pycache__"]);
  13. 14const CODE = /\.(?:[cm]?[jt]sx?|py|rb|go|php)$/;
  14. 15const SKIP_SELF = /(?:^|\/)stripe-webhook-hardening\//;
  15. 17function parseArgs(argv) {
  16. 18 const args = { root: ".", json: null, md: null };
  17. 19 const rest = [];
  18. 20 for (let i = 0; i < argv.length; i++) {
  19. 21 const a = argv[i];
  20. 22 if (a === "--json") args.json = argv[++i];
  21. 23 else if (a === "--md") args.md = argv[++i];
  22. 24 else if (a === "-h" || a === "--help") {
  23. 25 console.log("Usage: node scripts/webhook-scan.mjs [project-dir] [--json file] [--md file]");
  24. 26 process.exit(0);
  25. 27 } else rest.push(a);
  26. 28 }
  27. 29 if (rest[0]) args.root = rest[0];
  28. 30 return args;
  29. 33function walk(root, dir = root, out = []) {
  30. 34 for (const e of readdirSync(dir, { withFileTypes: true })) {
  31. 35 const full = join(dir, e.name);
  32. 36 if (e.isDirectory()) {
  33. 37 if (!SKIP_DIRS.has(e.name)) walk(root, full, out);
  34. 38 } else if (e.isFile() && CODE.test(e.name)) {
  35. 39 const rel = relative(root, full).split(sep).join("/");
  36. 40 if (!SKIP_SELF.test(rel) && statSync(full).size < 512 * 1024) out.push(rel);
  37. 41 }
  38. 42 }
  39. 43 return out;
  40. 46const lineOf = (text, re) => {
  41. 47 const m = re.exec(text);
  42. 48 return m ? text.slice(0, m.index).split("\n").length : 0;
  43. 49};
  44. 51const VERIFY = /\bwebhooks\.constructEvent(?:Async)?\s*\(|\bWebhook\.construct_event\s*\(|\bWebhook\.construct_event\b|\bStripe::Webhook\.construct_event\b|\bwebhook\.ConstructEvent\s*\(|\bWebhook::constructEvent\s*\(/;
  45. 52const HANDLES_EVENTS =
  46. 53 /\b(?:checkout\.session\.completed|invoice\.(?:paid|payment_succeeded|payment_failed)|customer\.subscription\.(?:created|updated|deleted)|payment_intent\.succeeded|charge\.(?:succeeded|refunded))\b|stripe-signature|Stripe-Signature|HTTP_STRIPE_SIGNATURE/;
  47. 54const PARSED_BODY = /\bawait\s+(?:req|request)\.json\(\)|\brequest\.get_json\(|\brequest\.json\b(?!\s*\()|JSON\.stringify\(\s*req\.body\s*\)/;
  48. 55const RAW_BODY =
  49. 56 /\bawait\s+(?:req|request)\.text\(\)|\barrayBuffer\(\)|express\.raw\(|bodyParser\.raw\(|bodyParser\s*:\s*false|\bbuffer\(\s*req\s*\)|\brawBody\b|\brequest\.(?:data|get_data\(|body\.read)|\bio\.ReadAll\(|php:\/\/input|getRawBody|req\.body\s*,\s*sig/;
  50. 57const JSON_PARSER = /\bexpress\.json\(|\bbodyParser\.json\(|app\.use\(\s*json\(/;
  51. 58const HARDCODED_SECRET = /["'`]whsec_[A-Za-z0-9]+["'`]/;
  52. 59const ENV_SECRET = /\benv\.[A-Z0-9_]*WEBHOOK|os\.environ(?:\.get)?[[(]\s*["'][A-Z0-9_]*WEBHOOK|ENV\[\s*["'][A-Z0-9_]*WEBHOOK|Deno\.env\.get\(\s*["'][A-Z0-9_]*WEBHOOK|getenv\(\s*["'][A-Z0-9_]*WEBHOOK|env\(\s*["'][A-Z0-9_]*WEBHOOK/;
  53. 60const IDEMPOTENT =
  54. 61 /\bevent\.id\b[\s\S]{0,400}?\b(?:insert|upsert|create|exists|findUnique|findFirst|processed|unique|on\s*conflict|onConflict|ignoreDuplicates|setnx|SETNX|get_or_create)\b|\b(?:processed_events|stripe_events|webhook_events|processedEvents|stripeEvents|webhookEvents)\b|idempotenc/i;
  55. 62const REJECTS_400 = /\b400\b|status_code\s*=\s*400|BadRequest|bad_request|HTTP_400/;
  56. 63const TYPE_FILTER = /\bswitch\s*\(\s*event\.type\s*\)|\bevent\.type\s*===?|\bevent\[["']type["']\]\s*==|\bevent\.type\s*==|\bcase\s+["']checkout\.session\.completed["']/;
  57. 64const EDGE = /export\s+const\s+runtime\s*=\s*["']edge["']|Deno\.serve\(|\bserve\(\s*async/;
  58. 65const ASYNC_VERIFY = /constructEventAsync\s*\(/;
  59. 66const CRYPTO_PROVIDER = /createSubtleCryptoProvider\(/;
  60. 68function checkFile(file, text) {
  61. 69 const verifies = VERIFY.test(text);
  62. 70 const handles = verifies || HANDLES_EVENTS.test(text);
  63. 71 if (!handles) return null;
  64. 72 const checks = [];
  65. 73 const add = (id, status, severity, line, message, fix) => checks.push({ id, status, severity, line, message, fix });
  66. 75 if (verifies)
  67. 76 add("signature", "pass", null, lineOf(text, VERIFY), "Verifies the Stripe-Signature header.", null);
  68. 77 else
  69. 78 add(
  70. 79 "signature",
  71. 80 "fail",
  72. 81 "high",
  73. 82 lineOf(text, HANDLES_EVENTS),
  74. 83 "Handles Stripe events without verifying the signature. Anyone who finds this URL can post a fake checkout.session.completed and get your product free.",
  75. 84 "Call stripe.webhooks.constructEvent(rawBody, signature, secret) before reading the event, with the secret from your STRIPE_WEBHOOK_SECRET env var.",
  76. 85 );
  77. 87 const parsed = PARSED_BODY.test(text);
  78. 88 const raw = RAW_BODY.test(text);
  79. 89 if (parsed && !raw)
  80. 90 add(
  81. 91 "raw-body",
  82. 92 "fail",
  83. 93 "high",
  84. 94 lineOf(text, PARSED_BODY),
  85. 95 "Reads the body as parsed JSON. Signature checks need the exact raw bytes, so verification fails (or was removed to make it pass).",
  86. 96 "Next.js App Router: const body = await req.text(). Express: express.raw({ type: 'application/json' }) on this route only. Pages Router: export const config = { api: { bodyParser: false } }.",
  87. 97 );
  88. 98 else if (raw) add("raw-body", "pass", null, lineOf(text, RAW_BODY), "Uses the raw request body.", null);
  89. 99 else if (JSON_PARSER.test(text))
  90. 100 add(
  91. 101 "raw-body",
  92. 102 "warn",
  93. 103 "medium",
  94. 104 lineOf(text, JSON_PARSER),
  95. 105 "A JSON body parser is set up in this file. If it runs before the webhook route, the raw body is gone.",
  96. 106 "Mount the webhook route with express.raw() before app.use(express.json()).",
  97. 107 );
  98. 108 else add("raw-body", "warn", "low", 0, "Could not tell how the body is read. Check by hand.", null);
  99. 110 if (HARDCODED_SECRET.test(text))
  100. 111 add(
  101. 112 "secret-from-env",
  102. 113 "fail",
  103. 114 "high",
  104. 115 lineOf(text, HARDCODED_SECRET),
  105. 116 "The webhook signing secret is hardcoded.",
  106. 117 "Move it to STRIPE_WEBHOOK_SECRET in your env and roll the secret in the Stripe dashboard.",
  107. 118 );
  108. 119 else if (verifies && !ENV_SECRET.test(text))
  109. 120 add("secret-from-env", "warn", "low", 0, "Could not see the signing secret being read from an env var here. Check where it comes from.", null);
  110. 121 else if (verifies) add("secret-from-env", "pass", null, lineOf(text, ENV_SECRET), "Reads the signing secret from env.", null);
  111. 123 if (IDEMPOTENT.test(text)) add("idempotency", "pass", null, lineOf(text, IDEMPOTENT), "Looks idempotent on event.id.", null);
  112. 124 else
  113. 125 add(
  114. 126 "idempotency",
  115. 127 "fail",
  116. 128 "medium",
  117. 129 0,
  118. 130 "No sign of de-duplicating by event.id. Stripe retries and can deliver the same event twice, so orders, credits, or emails can double up.",
  119. 131 "Insert event.id into a processed_events table with a unique constraint first; if the insert conflicts, return 200 and stop.",
  120. 132 );
  121. 134 if (verifies) {
  122. 135 if (REJECTS_400.test(text)) add("reject-bad-signature", "pass", null, lineOf(text, REJECTS_400), "Returns 400 on failure.", null);
  123. 136 else
  124. 137 add(
  125. 138 "reject-bad-signature",
  126. 139 "warn",
  127. 140 "low",
  128. 141 0,
  129. 142 "No 400 response found. Wrap verification in try/catch and return 400 when it throws, so bad requests are rejected and real failures show in the Stripe dashboard.",
  130. 143 null,
  131. 144 );
  132. 145 }
  133. 147 if (TYPE_FILTER.test(text)) add("event-types", "pass", null, lineOf(text, TYPE_FILTER), "Branches on event.type.", null);
  134. 148 else
  135. 149 add(
  136. 150 "event-types",
  137. 151 "warn",
  138. 152 "low",
  139. 153 0,
  140. 154 "Does not branch on event.type. Handle only the events you subscribed to and return 200 for the rest.",
  141. 155 null,
  142. 156 );
  143. 158 if (EDGE.test(text) && verifies && !ASYNC_VERIFY.test(text))
  144. 159 add(
  145. 160 "edge-runtime",
  146. 161 "fail",
  147. 162 "medium",
  148. 163 lineOf(text, EDGE),
  149. 164 "Runs on an edge or Deno runtime with the sync constructEvent, which needs Node crypto.",
  150. 165 "Use await stripe.webhooks.constructEventAsync(body, sig, secret, undefined, Stripe.createSubtleCryptoProvider()).",
  151. 166 );
  152. 167 else if (EDGE.test(text) && ASYNC_VERIFY.test(text) && !CRYPTO_PROVIDER.test(text))
  153. 168 add("edge-runtime", "warn", "low", lineOf(text, EDGE), "Edge runtime: pass Stripe.createSubtleCryptoProvider() to constructEventAsync if verification throws.", null);
  154. 170 return { file, checks };
  155. 173export function scan(root) {
  156. 174 const handlers = [];
  157. 175 for (const file of walk(root)) {
  158. 176 const text = readFileSync(join(root, file), "utf8");
  159. 177 const r = checkFile(file, text);
  160. 178 if (r) handlers.push(r);
  161. 179 }
  162. 180 const all = handlers.flatMap((h) => h.checks.filter((c) => c.status !== "pass"));
  163. 181 return {
  164. 182 summary: {
  165. 183 handlers: handlers.length,
  166. 184 high: all.filter((c) => c.severity === "high").length,
  167. 185 medium: all.filter((c) => c.severity === "medium").length,
  168. 186 low: all.filter((c) => c.severity === "low").length,
  169. 187 },
  170. 188 handlers,
  171. 189 };
  172. 192function toMarkdown(report) {
  173. 193 const s = report.summary;
  174. 194 const lines = ["# Stripe webhook scan", "", `Handlers found: ${s.handlers} | High: ${s.high} | Medium: ${s.medium} | Low: ${s.low}`, ""];
  175. 195 if (s.handlers === 0)
  176. 196 lines.push("No Stripe webhook handler found. Search for the route Stripe posts to (often api/webhooks/stripe) and point the scan at that folder.", "");
  177. 197 for (const h of report.handlers) {
  178. 198 lines.push(`## ${h.file}`, "", "| Check | Result | Line | Note |", "| --- | --- | --- | --- |");
  179. 199 for (const c of h.checks) {
  180. 200 const result = c.status === "pass" ? "pass" : `**${c.status.toUpperCase()}** (${c.severity})`;
  181. 201 lines.push(`| ${c.id} | ${result} | ${c.line || ""} | ${c.message.replace(/\|/g, "/")} |`);
  182. 202 }
  183. 203 const fixes = h.checks.filter((c) => c.fix);
  184. 204 if (fixes.length) {
  185. 205 lines.push("", "Fixes:", "");
  186. 206 for (const c of fixes) lines.push(`- ${c.id}: ${c.fix}`);
  187. 207 }
  188. 208 lines.push("");
  189. 209 }
  190. 210 return lines.join("\n");
  191. 213function write(path, text) {
  192. 214 mkdirSync(dirname(path), { recursive: true });
  193. 215 writeFileSync(path, text);
  194. 218function main() {
  195. 219 const args = parseArgs(process.argv.slice(2));
  196. 220 if (!existsSync(args.root) || !statSync(args.root).isDirectory()) {
  197. 221 console.error(`No folder at ${args.root}.`);
  198. 222 process.exit(2);
  199. 223 }
  200. 224 const report = scan(args.root);
  201. 225 const md = toMarkdown(report);
  202. 226 if (args.json) write(args.json, JSON.stringify(report, null, 2) + "\n");
  203. 227 if (args.md) write(args.md, md);
  204. 228 if (!args.json && !args.md) console.log(md);
  205. 229 else console.log(`${report.summary.handlers} handlers. High ${report.summary.high}, medium ${report.summary.medium}, low ${report.summary.low}.`);
  206. 232main();