Viết webhook receiver đứng vững trước chữ ký giả, replay và sự kiện trùng
Stripe có thể gửi lại cùng một sự kiện trong tối đa ba ngày, mỗi lần với chữ ký mới. Vì thế cửa sổ thời gian không chặn được sự kiện trùng, và receiver của bạn cần thêm một lớp phòng thủ riêng.
Tóm tắt nhanh
- Tính chữ ký trên byte thô của body. Nếu framework parse rồi serialize lại JSON thì chữ ký sẽ không bao giờ khớp.
- Timestamp nằm trong phần được ký nên chặn được replay. Retry hợp lệ thì có chữ ký mới, nên việc lọc trùng phải dựa vào event ID.
- Trả 2xx trước mọi logic nặng, đẩy việc xử lý vào hàng đợi, và không bao giờ đặt tolerance bằng 0.
- 1Giữ body thôLấy byte gốc trước khi framework parse JSON, nếu không chữ ký sẽ không khớp
- 2Xác thực HMAC-SHA256Ký timestamp.body bằng secret, so sánh constant-time với từng chữ ký nhận được
- 3Kiểm tra timestampTừ chối nếu lệch quá 5 phút; không bao giờ đặt tolerance bằng 0
- 4Trả 2xx, đẩy vào hàng đợiTrả lời nhanh trước logic nặng để tránh timeout và retry
- 5Lọc trùng bằng event IDGhi ID cùng transaction với thay đổi nghiệp vụ; không dùng created để suy thứ tự
Mỗi bước chặn một kiểu lỗi riêng, và việc lọc trùng phải dựa vào event ID vì retry luôn có chữ ký mới.
Đồ hoạ: FDE Times
Stripe thử giao một sự kiện webhook trong tối đa ba ngày ở live mode, với khoảng chờ tăng dần (exponential backoff). Mỗi lần thử lại mang timestamp mới và chữ ký mới.
Nghĩa là cửa sổ chống replay 5 phút sẽ không chặn được một retry hợp lệ. Nếu receiver của bạn chỉ có lớp phòng thủ đó, khách hàng có thể bị trừ kho hai lần hoặc nhận hai email xác nhận cho cùng một đơn hàng.
Webhook có mặt trong nhiều loại tích hợp mà một FDE có thể phải dựng: thanh toán với Stripe, CI/CD với GitHub, bot nội bộ với Slack. Bài này hướng dẫn dựng một receiver có ba lớp phòng thủ: xác thực chữ ký, chống replay và lọc sự kiện trùng.
Bạn sẽ dựng gì, cần chuẩn bị gì?
Kết quả cuối cùng là vài hàm Python chỉ dùng thư viện chuẩn, không cần framework, mô phỏng cách Stripe ký webhook. Bạn cần Python 3 và các module hmac, hashlib, sqlite3, json. Phần nhận HTTP được lược bớt có chủ ý. Framework nào cũng làm được việc đó, miễn là bạn lấy được byte thô của body.
Một lưu ý trước khi bắt đầu: code dưới đây đã được đơn giản hoá để học cơ chế. Ở dự án thật, hãy dùng thư viện chính thức của nhà cung cấp, vì nó tự tách header và xử lý những chi tiết mà ví dụ này bỏ qua.
Bước 1: vì sao phải giữ body thô?
Tài liệu của Stripe nói rõ: việc xác thực chữ ký cần raw body của request, và framework không được biến đổi nó. Đây là lỗi hay gặp khi viết receiver. Middleware parse JSON rồi bạn serialize lại, chỉ cần khác một dấu cách hay thứ tự key là HMAC đã khác hoàn toàn.
Quy tắc thì đơn giản: lấy byte thô trước, xác thực chữ ký, rồi mới parse JSON. Bạn sẽ tự kiểm chứng điều này ở bước 5.
Bước 2: tính chữ ký và so sánh đúng cách
Stripe mô tả cách tính chữ ký thủ công như sau. Ghép timestamp, dấu chấm và body thành signed_payload, rồi tính HMAC với hàm băm SHA256, dùng signing secret của endpoint làm khóa. Khi so sánh, Stripe yêu cầu dùng phép so sánh chuỗi constant-time giữa chữ ký kỳ vọng và từng chữ ký nhận được, để chống timing attack.
import hmac, hashlib, time
TOLERANCE_SECONDS = 300 # 5 phút
def expected_signature(secret: bytes, timestamp: str, raw_body: bytes) -> str:
signed_payload = timestamp.encode() + b"." + raw_body
return hmac.new(secret, signed_payload, hashlib.sha256).hexdigest()
def verify(secret, timestamp, raw_body, received_sigs, now=None):
now = now if now is not None else time.time()
if abs(now - int(timestamp)) > TOLERANCE_SECONDS:
return False
exp = expected_signature(secret, timestamp, raw_body)
return any(hmac.compare_digest(exp, s) for s in received_sigs)
Ví dụ này đã được đơn giản hoá: việc tách timestamp và danh sách chữ ký ra khỏi header được bỏ qua. Hãy để ý vòng any(...): có thể có nhiều chữ ký được gửi kèm, và chỉ cần một chữ ký khớp. Nếu bạn viết exp == s, code vẫn chạy và test vẫn qua, nhưng bạn đã mở đúng lỗ hổng timing attack mà Stripe cảnh báo.
Kiểm tra: gọi expected_signature(b"whsec_test", "1700000000", b'{"id":"evt_1"}') hai lần. Hai kết quả phải giống hệt nhau.
Bước 3: chống replay, và đừng bao giờ đặt tolerance bằng 0
Timestamp nằm trong phần được ký, nên kẻ tấn công không thể sửa nó mà không làm hỏng chữ ký. Thư viện của Stripe mặc định cho phép lệch 5 phút giữa timestamp và giờ hiện tại. Slack làm tương tự: chữ ký phụ thuộc timestamp để chống replay, và ví dụ chính thức của Slack cũng kiểm tra độ lệch không quá 5 phút.
Cái bẫy nằm ở cấu hình. Stripe cảnh báo thẳng: tolerance bằng 0 sẽ tắt hoàn toàn việc kiểm tra độ mới. Ai đó gặp lỗi lệch đồng hồ lúc 2 giờ sáng, đặt tolerance về 0 “cho tạm qua”, và receiver mất luôn lớp chống replay mà không hệ thống giám sát nào báo động.
Kiểm tra: gọi verify với now lớn hơn timestamp 301 giây. Kết quả phải là False.
Bước 4: lọc trùng bằng event ID, không bằng thời gian
Như đã nói ở đầu bài, retry hợp lệ có chữ ký mới nên vượt qua được bước 3. Stripe khuyên ghi lại các event ID đã xử lý và bỏ qua những sự kiện đã có trong log.
Stripe cũng nhấn mạnh rằng thứ tự giao sự kiện không được đảm bảo. Đừng dùng trường created để suy ra thứ tự hay để đoán một sự kiện đã được xử lý chưa.
import sqlite3
db = sqlite3.connect("events.db")
db.execute("CREATE TABLE IF NOT EXISTS processed (event_id TEXT PRIMARY KEY)")
def first_time(event_id: str) -> bool:
# Không commit ở đây: người gọi quyết định khi nào commit
cur = db.execute(
"INSERT OR IGNORE INTO processed(event_id) VALUES (?)", (event_id,))
return cur.rowcount == 1
Có hai điểm tinh tế. Stripe lưu ý rằng đôi khi hai Event khác nhau cùng mô tả một việc. Khi đó, khóa lọc trùng nên là ID của data.object ghép với event.type.
Điểm còn lại nằm ở thứ tự ghi. Hãy ghi event ID trong cùng transaction với thay đổi nghiệp vụ. Nếu đánh dấu “đã xử lý” và commit trước, rồi worker chết giữa chừng, sự kiện đó sẽ mất hẳn. Đó là lý do hàm trên không tự commit.
Kiểm tra: first_time("evt_1") trả True ở lần gọi đầu và False ở lần thứ hai.
Bước 5: trả 2xx trước, xử lý sau
Stripe yêu cầu endpoint trả mã 2xx thật nhanh, trước mọi logic phức tạp có thể gây timeout. Họ khuyên xử lý bất đồng bộ qua hàng đợi để chịu được những đợt sự kiện tăng đột biến. Đoạn code rút gọn dưới đây nối các bước trước thành một receiver hoàn chỉnh:
import json, queue
# Secret giả để thử trên máy; ở dự án thật, đọc signing secret từ biến môi trường
SECRET = b"whsec_test"
jobs = queue.Queue()
def handle(timestamp, sigs, raw_body):
if not verify(SECRET, timestamp, raw_body, sigs):
return 400
jobs.put(json.loads(raw_body))
return 200
def worker():
while True:
event = jobs.get()
with db: # một transaction: commit nếu xong, rollback nếu lỗi
if first_time(event["id"]):
apply_business_change(db, event) # hàm nghiệp vụ của bạn
Nhờ with db, việc ghi event ID và thay đổi nghiệp vụ cùng thành công hoặc cùng bị huỷ. Code vẫn là bản đơn giản hoá: hàng đợi trong bộ nhớ sẽ mất việc nếu tiến trình chết, và apply_business_change là chỗ bạn tự điền.
Giờ hãy chạy năm bài test:
- Chữ ký đúng phải qua.
- Sửa một byte của body thì phải trượt.
- Timestamp cũ hơn 5 phút thì phải trượt.
- Cùng event ID gửi hai lần thì chỉ được xử lý một lần.
- Ký trên một body JSON viết liền, không dấu cách, như
b'{"id":"evt_1"}', rồi xác thực bằngjson.dumps(json.loads(raw_body)).encode(). Kết quả phải trượt.
Bài thứ năm cần body viết liền vì json.dumps không phải lúc nào cũng làm đổi byte. Với separator mặc định, nó chèn một dấu cách sau dấu hai chấm và dấu phẩy, nên {"id":"evt_1"} thành {"id": "evt_1"} và chữ ký không còn khớp. Đây là bài đáng giá nhất, vì nó tái hiện đúng lỗi middleware ở bước 1.
Mỗi nhà cung cấp ký một kiểu khác nhau
Ở công ty khách hàng, bạn hiếm khi chỉ làm với Stripe. Cơ chế giống nhau nhưng chi tiết khác nhau, và chính những chi tiết này gây lỗi:
| Nguồn | Chuỗi được ký / header | Lưu ý |
|---|---|---|
| Stripe | timestamp.body, HMAC-SHA256 |
Tolerance mặc định 5 phút, không đặt bằng 0 |
| GitHub | Payload với secret token, hex digest trong X-Hub-Signature-256 |
So sánh constant-time, xử lý payload dạng UTF-8 |
| Slack | v0:timestamp:body, so với X-Slack-Signature |
Ví dụ chính thức kiểm tra lệch tối đa 5 phút |
| Standard Webhooks | webhook-id, webhook-timestamp, webhook-signature |
Dùng webhook-id làm idempotency key |
Nếu khách hàng tự xây hệ thống phát webhook, hãy gợi ý họ theo đặc tả Standard Webhooks. Ba header ở đó đã tách sẵn đúng ba việc bạn vừa làm: ID để lọc trùng, timestamp để chống replay, chữ ký để xác thực.
Kỹ năng này dùng vào đâu trong công việc FDE?
Thử hình dung tuần đầu ở một khách hàng bán lẻ. Đội vận hành than rằng “thỉnh thoảng đơn bị ghi nhận hai lần”.
Một FDE giỏi sẽ không đoán mò mà kiểm tra theo thứ tự trong bài: handler có xác thực trên body thô không, có bảng event ID không, có làm việc nặng trước khi trả 2xx dẫn tới timeout và retry không.
Khi đi xin việc, nếu JD có chữ “integrations” hay “event-driven”, hãy hỏi trong buổi phỏng vấn xem đội đó xử lý sự kiện trùng thế nào.
Trong CV, viết cụ thể, ví dụ “dựng webhook receiver có xác thực HMAC, chống replay và lọc trùng theo event ID, xử lý bất đồng bộ qua hàng đợi”. Câu đó cho người tuyển biết bạn hiểu hệ thống phân tán đủ sâu để không làm hỏng dữ liệu của khách hàng.
Webhook là hợp đồng giữa hai hệ thống không tin nhau, nối với nhau qua một mạng không đáng tin. Receiver tốt không giả định gói tin nào cũng thật, cũng mới, hay chỉ đến một lần.