Kỹ thuật30 tháng 7, 20267 phút đọc

Test chữ ký webhook của bạn chẳng chứng minh được gì

Nếu test dựng chữ ký bằng đúng công thức mà hàm xác minh kiểm tra, cả hai có thể cùng sai mà vẫn xanh. Hãy đối chiếu với SDK của nhà cung cấp.

MỘT BÀI TEST CHỮ KÝ CHẠY XANH THỰC SỰ KIỂM TRA ĐƯỢC GÌfixture của bạn → verifier của bạncả hai dựa trên cùng một giả địnhrequest thậtbody đã parse · proxy sửa · timestamp cũ · key đã xoaybài test không bao giờ chạm tới tầng đã đọc hết stream trước khi code của bạn chạybài test duy nhất chứng minh được điều gì là gửi một lượt gửi thật, có chữ ký, qua đúng stack thật
Test và đoạn code được test thường mang cùng một giả định sai.

Đây là một test chữ ký webhook vẫn pass, trông rất có trách nhiệm, và gần như chẳng chứng minh được gì:

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 được dựng bằng đúng công thức mà hàm xác minh kiểm tra. Nếu bạn đọc sai tài liệu của Stripe khi viết hàm xác minh (ký sai chuỗi, nối bằng sai ký tự phân cách, dùng hex trong khi lẽ ra phải là base64), test của bạn mã hóa đúng cái hiểu sai đó và cả hai vẫn xanh. Bạn chưa test “chúng ta xác minh chữ ký Stripe”. Bạn chỉ test “chúng ta đồng ý với chính mình”.

Chúng tôi từng có 70 test y hệt như vậy cho 20 nhà cung cấp. Tất cả đều pass. Rồi chúng tôi hỏi câu hỏi duy nhất quan trọng: một chữ ký do chính code của nhà cung cấp tạo ra có qua được hàm xác minh của chúng tôi không?

HMAC TÍNH TRÊN BYTE, KHÔNG PHẢI TRÊN Ý NGHĨAbyte thô nhận được{"id":"evt_1","n":2}req.text()hmackhớpparse + stringify lại{ "n": 2, "id": "evt_1" }req.json()hmackhông khớpcùng dữ liệucùng dữ liệu. khác thứ tự key,khác khoảng trắng, và payloadtrông vẫn hoàn hảo trong log.
Lỗi mà fixture không tái hiện được: framework của bạn đã đọc hết stream, nên các byte bạn băm không phải các byte đã được ký.

Đối chiếu với SDK của nhà cung cấp

Một số nhà cung cấp kèm sẵn hàm ký trong SDK chính thức, thường là để phục vụ đúng mục đích này. Của Stripe là generateTestHeaderString; class Webhook của Svix có .sign(). Nếu chúng vốn đã là dependency của bạn, đối chiếu chẳng tốn gì:

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);
});

Giờ phép khẳng định là chúng ta đồng ý với Stripe, chứ không phải đồng ý với chính mình. Thêm trường hợp âm tính (ký một payload, rồi xác minh một body khác) để biết test thực sự có thể fail:

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);

Vì Clerk và Resend đều gửi qua hạ tầng của Svix, một lần đối chiếu với Svix bao được ba nhà cung cấp. Bốn trong hai mươi hàm xác minh của chúng tôi giờ đã có bằng chứng dựa trên SDK, và công sức của cùng buổi chiều đó áp dụng được cho bất kỳ stack nào.

Còn những nhà cung cấp không công khai hàm ký thì sao?

Shopify, Discord, PayPal: phần lớn nhà cung cấp không kèm hàm ký. Với họ, bằng chứng trung thực duy nhất là một lượt gửi thật được ghi lại. Trỏ một webhook tới endpoint bạn kiểm soát, ghi lại một request đã ký, rồi đóng băng bộ ba (headers, body, secret) thành fixture. Một chữ ký thật được phát lại là sự thật nền tảng mà không fixture tự dựng nào có được.

Cho tới khi làm được điều đó, hãy chính xác với bản thân về những gì test của bạn thể hiện. Chúng tôi giữ một sổ ghi trong comment ở cuối file test: nhà cung cấp nào đã được xác minh bằng SDK, nhà cung cấp nào chỉ tự nhất quán, nhà cung cấp nào hoàn toàn chưa có test dương tính. Một danh sách trung thực mà bạn hành động được vẫn hơn một bộ test xanh mà bạn không tin được.

Bug mà lối nghĩ này tìm ra ngay trong ngày

Mối nghi ngờ đó cũng áp dụng ngoài unit test. Trong lúc kiểm tra end-to-end luồng Resend trên production (không phải bộ unit test, mà là hệ thống thật đã deploy), chúng tôi gửi ba request tới một endpoint đã cấu hình signing secret (khóa bí mật dùng để ký):

RequestKỳ vọngThực tế
Chữ ký đúng200200
Chữ ký sai401401
Hoàn toàn không có chữ ký401200

Một request không có chữ ký lướt qua một endpoint trông như được bảo vệ. Nguyên nhân là một lỗ hổng chính sách, không phải bug mã hóa. Các App (một URL nhận cho một nguồn webhook, ví dụ “Stripe production”) có nguồn generic bỏ qua xác minh theo thiết kế (“tôi chỉ cần một URL”), và cấu hình secret cũng không đưa App ra khỏi generic. Secret nằm đó không xác minh gì cả, trong khi API vẫn trả về thành công khi lưu nó.

Không bao nhiêu unit test tự nhất quán tìm ra được điều đó, vì từng unit đều đúng. Cách sửa là từ chối thẳng cấu hình gây hiểu lầm: đặt signing secret cho một App generic giờ trả về 400 kèm thông báo bạn nên đặt trường nào thay vào.

Checklist

  1. Với mọi nhà cung cấp có hàm ký trong SDK: đối chiếu theo cả hai chiều (chữ ký hợp lệ thì pass, payload bị sửa thì fail)
  2. Với phần còn lại: ghi lại một lượt gửi thật có chữ ký và đóng băng nó thành fixture
  3. Giữ một sổ ghi dễ thấy về việc nhà cung cấp nào có mức bằng chứng nào
  4. Test hệ thống đã deploy với trường hợp không có chữ ký, không chỉ với chữ ký sai. Các lối vượt rào nằm ở chính sách, không nằm ở toán học
Tất cả bài viết30 tháng 7, 2026 · 7 phút

Đừng để mất webhook nữa.

Đổi một URL. Có ngay cơ chế thử lại, nhật ký sự kiện và phát lại bằng một cú bấm. Hoặc xem tận mắt trước: đăng ký, chạy bài diễn tập sự cố 30 giây và xem một sự kiện vẫn còn nguyên khi server của bạn ngừng hoạt động. Không cần có server.