Rekayasa30 Juli 20267 mnt baca

Tes Tanda Tangan (Signature) Webhook Tidak Membuktikan Apa Pun

Jika tes Anda membuat tanda tangan dengan rumus yang diperiksa verifier, keduanya bisa sama-sama salah dan tetap hijau. Cek silang dengan SDK provider.

CAKUPAN NYATA TES TANDA TANGAN YANG LULUSfixture Anda → verifier Andakeduanya dibuat dari asumsi yang samapermintaan yang sebenarnyabody hasil parse · rewrite proxy · stempel waktu retry · rotasi kuncites itu tidak pernah menyentuh lapisan yang mengonsumsi stream sebelum kode Anda berjalansatu-satunya tes yang membuktikan sesuatu mengirim webhook bertanda tangan asli lewat stack yang asli
Tes dan kode yang diujinya biasanya berbagi asumsi keliru yang sama.

Berikut tes tanda tangan (signature) webhook yang lolos, terlihat bertanggung jawab, dan hampir tidak membuktikan apa pun:

const ts  = Math.floor(Date.now() / 1000).toString();
const sig = await hmacHex(`${ts}.${body}`, secret, "SHA-256");
const headers = new Headers({ "stripe-signature": `t=${ts},v1=${sig}` });

expect(await verifySignature("stripe", headers, body, secret)).toBe(true);

Fixture-nya dibangun dengan rumus yang sama dengan yang diperiksa verifier. Jika Anda salah membaca dokumentasi Stripe saat menulis verifier (menandatangani string yang salah, menggabungkan dengan pemisah yang salah, hex padahal seharusnya base64), tes Anda ikut membawa salah baca yang sama dan keduanya tetap hijau. Anda belum menguji “kami memverifikasi tanda tangan Stripe.” Anda menguji “kami setuju dengan diri sendiri.”

Kami punya 70 tes persis seperti ini untuk 20 provider. Semuanya lolos. Lalu kami mengajukan satu-satunya pertanyaan yang penting: apakah tanda tangan yang dihasilkan kode milik provider sendiri akan lolos verifier kami?

HMAC DIHITUNG DARI BYTE, BUKAN DARI MAKNAbyte dari jaringan{"id":"evt_1","n":2}req.text()hmaccocokparse + stringify ulang{ "n": 2, "id": "evt_1" }req.json()hmactidak cocokdatanya samadatanya sama. urutan key berbeda,spasi berbeda, dan payload-nyatampak sempurna di log Anda.
Kegagalan yang tidak bisa direproduksi fixture: framework Anda sudah menghabiskan stream, jadi byte yang Anda hash bukan byte yang ditandatangani.

Cek silang dengan SDK provider

Beberapa provider menyertakan rutin penandatanganan di SDK resminya, biasanya memang untuk keperluan ini. Milik Stripe adalah generateTestHeaderString; class Webhook milik Svix punya .sign(). Jika keduanya sudah menjadi dependensi Anda, cek silang tidak memakan biaya apa pun:

import Stripe from "stripe";
import { Webhook } from "svix";
import { verifySignature } from "./index";

it("accepts a header generated by stripe-node", async () => {
  const stripe  = new Stripe("sk_test_" + "0".repeat(24));
  const payload = JSON.stringify({ id: "evt_1", type: "charge.succeeded" });
  const secret  = "whsec_" + "s".repeat(32);

  const header = stripe.webhooks.generateTestHeaderString({ payload, secret });

  expect(
    await verifySignature("stripe", new Headers({ "stripe-signature": header }), payload, secret)
  ).toBe(true);
});

it("accepts a signature generated by the svix SDK", async () => {
  const secret  = "whsec_" + Buffer.from("k".repeat(24)).toString("base64");
  const payload = JSON.stringify({ type: "email.received" });
  const msgId   = "msg_crosscheck";
  const ts      = new Date();

  const signature = new Webhook(secret).sign(msgId, ts, payload);
  const headers = new Headers({
    "svix-id": msgId,
    "svix-timestamp": Math.floor(ts.getTime() / 1000).toString(),
    "svix-signature": signature,
  });

  expect(await verifySignature("svix", headers, payload, secret)).toBe(true);
});

Sekarang yang diuji adalah bahwa kami sepakat dengan Stripe, bukan bahwa kami sepakat dengan diri sendiri. Tambahkan kasus negatif (tandatangani satu payload, lalu verifikasi body yang berbeda) agar Anda tahu tes itu benar-benar bisa gagal:

const header   = stripe.webhooks.generateTestHeaderString({ payload, secret });
const tampered = JSON.stringify({ id: "evt_1", amount: 999999 });

expect(
  await verifySignature("stripe", new Headers({ "stripe-signature": header }), tampered, secret)
).toBe(false);

Karena Clerk dan Resend sama-sama mengirim lewat infrastruktur Svix, satu cek silang Svix mencakup tiga provider. Empat dari dua puluh verifier kami kini punya bukti yang didukung SDK, dan pekerjaan satu sore yang sama berlaku untuk stack apa pun.

Bagaimana dengan provider yang tidak punya signer publik?

Shopify, Discord, PayPal: sebagian besar provider tidak menyertakan rutin penandatanganan. Untuk provider seperti itu, satu-satunya bukti yang jujur adalah pengiriman sungguhan yang ditangkap. Arahkan webhook ke endpoint yang Anda kendalikan, rekam satu permintaan yang ditandatangani, dan bekukan triple (headers, body, secret) sebagai fixture. Tanda tangan sungguhan yang diputar ulang (replay) adalah kebenaran dasar yang tidak bisa digantikan fixture buatan sendiri.

Sampai Anda melakukannya, akui dengan tepat apa yang sebenarnya ditunjukkan tes Anda. Kami mencatat daftarnya dalam komentar di bagian bawah file tes: provider mana yang terverifikasi SDK, mana yang hanya konsisten dengan dirinya sendiri, mana yang sama sekali tidak punya tes positif. Daftar jujur yang bisa Anda tindak lanjuti lebih baik daripada suite hijau yang tidak bisa Anda percayai.

Bug yang ditemukan pola pikir ini di hari yang sama

Kecurigaan yang sama berlaku di luar unit test. Saat memverifikasi jalur Resend kami secara end-to-end terhadap production (bukan suite unit, melainkan sistem sungguhan yang sudah di-deploy), kami mengirim tiga permintaan ke endpoint yang sudah dikonfigurasi dengan signing secret:

PermintaanDiharapkanDidapat
Tanda tangan benar200200
Tanda tangan salah401401
Tanpa tanda tangan sama sekali401200

Permintaan tanpa tanda tangan melenggang melewati endpoint yang terlihat terlindungi. Penyebabnya celah kebijakan, bukan bug kriptografi. App (satu URL masuk untuk satu sumber webhook) yang sumbernya generic sengaja melewati verifikasi (“saya hanya ingin sebuah URL”), dan mengonfigurasi secret tidak memindahkan App itu keluar dari generic. Secret itu diam di sana tanpa memverifikasi apa pun, sementara API mengembalikan status sukses saat menyimpannya.

Sebanyak apa pun unit test yang konsisten dengan dirinya sendiri tidak akan menemukannya, karena setiap unit sudah benar. Perbaikannya adalah menolak konfigurasi yang menyesatkan itu sejak awal: memasang signing secret pada App generic kini menghasilkan 400 yang memberi tahu field mana yang seharusnya Anda isi.

Daftar periksa

  1. Untuk setiap provider yang SDK-nya menyertakan signer: lakukan cek silang, dua arah (yang valid lolos, yang diubah gagal)
  2. Untuk sisanya: tangkap satu pengiriman sungguhan yang ditandatangani dan bekukan sebagai fixture
  3. Simpan catatan yang terlihat tentang provider mana yang punya tingkat bukti apa
  4. Uji sistem yang sudah di-deploy dengan permintaan tanpa tanda tangan, bukan hanya dengan tanda tangan yang salah. Celah bypass ada di kebijakan, bukan di matematika
Semua artikel30 Juli 2026 · 7 mnt

Berhenti kehilangan webhook.

Ganti satu URL. Dapatkan percobaan ulang, log event, dan putar ulang sekali klik. Atau lihat dulu: daftar dan jalankan latihan gangguan 30 detik, lalu saksikan event tetap selamat saat server Anda mati. Tidak perlu server.