Bảo mật

Xác thực webhook thanh toán: chữ ký HMAC, chống phát lại và đối chiếu

Ai biết URL đều gửi được webhook "đã thanh toán". Thêm từng lớp: HMAC trên byte thô, timestamp, mã sự kiện, đối chiếu đơn, xoay khóa, và đếm mỗi lớp chặn gì.

Mục lục
  1. 1. Endpoint chỉ đọc JSON
  2. 2. Chữ ký HMAC-SHA256 trên byte thô
  3. 3. So sánh hằng thời gian
  4. 4. Chống phát lại: timestamp và mã sự kiện
  5. 5. Đối chiếu với dữ liệu của mình, trả 2xx trước khi xử lý
  6. 6. Xoay vòng khóa bí mật
  7. 7. Ghép các lớp và đếm kịch bản lọt
  8. Những chỗ hay hiểu sai
  9. Đọc tiếp
  10. Nguồn

02:13 sáng Chủ nhật 2026-09-27, endpoint /webhook/cong-thanh-toan của BanHang nhận một request báo đơn 10088 đã được thanh toán 18.990.000 đ. Endpoint đọc JSON, thấy loại sự kiện là thanh toán thành công, và chuyển đơn sang Đã thanh toán. 08:00, kho tổng Hà Nội xuất máy theo lịch. Cổng thanh toán đối tác chưa từng gửi request đó. Người gửi chỉ biết URL, lấy từ một file cấu hình bị đẩy nhầm lên repo công khai (diễn biến là minh họa). Bài này dựng lại lỗi bằng Python chạy local, thêm từng lớp phòng thủ, và đếm mỗi lớp chặn được kịch bản nào.

Đọc nhanh

  • Endpoint chỉ đọc JSON thì ai biết URL cũng chuyển được đơn sang Đã thanh toán. Cổng ký HMAC-SHA256 trên chuỗi t.body bằng khóa bí mật chung, BanHang tính lại trên đúng các byte nhận được và so bằng hmac.compare_digest.
  • Parse JSON rồi serialize lại trước khi kiểm làm hỏng chữ ký. Mỗi cách serialize lại thử trong bài từ chối cả 1.000 webhook hợp lệ ở ít nhất một định dạng của cổng; kiểm trên byte thô từ chối 0.
  • Timestamp trong chữ ký chặn webhook cũ hơn 5 phút. Mã sự kiện lưu trong bảng có khóa chính chặn phần còn lại: phát lại trong 5 phút và các lần cổng tự gửi lại.
  • Chữ ký chỉ chứng minh người gửi giữ khóa. Vẫn phải đối chiếu mã đơn và số tiền với dbo.DonHang, hỏi lại cổng khi nghi ngờ, và xoay khóa với hai khóa cùng hiệu lực. Danh sách IP, HTTPS hay URL bí mật không thay được chữ ký.

1. Endpoint chỉ đọc JSON

Đoạn dưới dựng lại endpoint cũ bằng http.server của thư viện chuẩn. Bảng don trong sqlite3 đóng vai dbo.DonHang. Kẻ tấn công gửi một JSON đúng dạng cổng hay gửi, đoán được từ tài liệu tích hợp hoặc từ một webhook thật từng lọt vào log. Mọi đoạn Python trong bài chạy trên Python 3.14.3, chỉ dùng thư viện chuẩn. Lưu thành ngay_tho.py:

import json, sqlite3, threading, urllib.request
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer

DB = sqlite3.connect(":memory:", check_same_thread=False)
DB.execute("CREATE TABLE don (ma_don INTEGER PRIMARY KEY, so_tien INTEGER, trang_thai TEXT)")
DB.execute("INSERT INTO don VALUES (10088, 18990000, 'moi')")

class Webhook(BaseHTTPRequestHandler):
    def do_POST(self):                         # chỉ đọc JSON rồi cập nhật đơn
        sk = json.loads(self.rfile.read(int(self.headers["Content-Length"])))
        if sk["loai"] == "thanh_toan.thanh_cong":
            with DB:                           # kho nghe trạng thái này để xuất hàng
                DB.execute("UPDATE don SET trang_thai = 'da_thanh_toan' WHERE ma_don = ?",
                           (sk["du_lieu"]["ma_don"],))
        self.send_response(200)
        self.end_headers()

    log_message = lambda self, *args: None    # tắt log truy cập cho gọn

may_chu = ThreadingHTTPServer(("127.0.0.1", 0), Webhook)
threading.Thread(target=may_chu.serve_forever, daemon=True).start()
gia = (b'{"id":"evt_X1","loai":"thanh_toan.thanh_cong",'
       b'"du_lieu":{"ma_don":10088,"so_tien":18990000}}')   # không cần biết gì ngoài URL
url = f"http://127.0.0.1:{may_chu.server_port}/webhook/cong-thanh-toan"
print(urllib.request.urlopen(url, gia).status, DB.execute("SELECT * FROM don").fetchone())
200 (10088, 18990000, 'da_thanh_toan')

Endpoint làm đúng điều nó được viết: tin mọi request đến URL. Kẻ đã có URL gửi được bao nhiêu webhook giả cũng được, cho đơn nào cũng được, và mỗi webhook là một máy rời kho. Cách sửa là để BanHang biết request do cổng gửi, nội dung chưa bị sửa, chưa từng được xử lý, và khớp một đơn thật. Sơ đồ dưới là luồng sau khi thêm đủ các lớp. Mỗi mục sau đi qua một bước.

sequenceDiagram
  participant Cong as Cổng
  participant API as Endpoint
  participant DB as WebhookNhan
  participant Nen as Xử lý nền
  Cong->>API: POST, X-Signature t, v1
  Note over API: HMAC byte thô, lệch giờ ≤ 300 s
  API->>DB: INSERT mã sự kiện
  API-->>Cong: 200 (sai chữ ký: 400)
  Nen->>DB: Lấy sự kiện chưa xử lý
  Nen->>Cong: Hỏi lại giao dịch khi nghi ngờ
  Note over Nen: Khớp dbo.DonHang thì đơn sang Đã thanh toán

2. Chữ ký HMAC-SHA256 trên byte thô

BanHang và cổng chia sẻ một khóa bí mật 32 byte, sinh bằng secrets.token_bytes(32) và cất trong kho bí mật, không trong file cấu hình. RFC 2104 gọi khóa ngắn hơn độ dài đầu ra của hàm băm (32 byte với SHA-256) là "strongly discouraged". Mỗi lần gửi, cổng ghép timestamp, một dấu chấm và body thành chuỗi được ký, tính HMAC-SHA256 bằng khóa, rồi gửi kết quả dạng hex trong header. Không có khóa thì không tính được chữ ký đúng cho một body tự chọn. Đổi một byte của body hay của timestamp là chữ ký không khớp. Code dưới hình lưu thành chu_ky.py; các đoạn sau dùng lại hai hàm ky và kiem.

Header cổng gửi kèm request X-Signature: t=1790825400 , v1=<64 ký tự hex> 1790825400 . {"id":"evt_P1",…} HMAC-SHA256 so với v1 bằng compare_digest khóa bí mật 32 byte timestamp body: đúng các byte nhận được chuỗi được ký: đổi một byte, kể cả t, là hỏng chữ ký
Timestamp nằm trong chuỗi được ký nên không sửa được mà giữ nguyên chữ ký.
import hashlib, hmac

def ky(body: bytes, cac_khoa: list[bytes], t: int) -> str:   # phía cổng, mỗi khóa một v1
    chuoi = str(t).encode() + b"." + body
    return ",".join([f"t={t}"] + [f"v1={hmac.new(k, chuoi, hashlib.sha256).hexdigest()}"
                                  for k in cac_khoa])

def kiem(header: str, body: bytes, cac_khoa: list[bytes], bay_gio: int,
         dung_sai: int | None = 300) -> str | None:   # phía BanHang: None là hợp lệ
    cap = [phan.partition("=") for phan in header.split(",")]
    t = [v for k, _, v in cap if k == "t"]
    chu_ky = [v.encode() for k, _, v in cap if k == "v1"]      # bỏ qua lược đồ khác v1
    if len(t) != 1 or not (t[0].isascii() and t[0].isdigit()) or not chu_ky:
        return "header sai dạng"
    chuoi = t[0].encode() + b"." + body                         # byte thô, đúng như nhận
    ky_vong = [hmac.new(k, chuoi, hashlib.sha256).hexdigest().encode() for k in cac_khoa]
    if not any(hmac.compare_digest(a, b) for a in ky_vong for b in chu_ky):
        return "chữ ký không khớp"
    if dung_sai is not None and abs(bay_gio - int(t[0])) > dung_sai:
        return "ngoài cửa sổ thời gian"
    return None

Ba chi tiết trong kiem. Chữ ký được so dưới dạng bytes, vì hmac.compare_digest chỉ nhận str toàn ký tự ASCII; header có ký tự khác làm nó ném TypeError, và request rác thành lỗi 500 thay vì 400. Lược đồ khác v1 bị bỏ qua, như Stripe yêu cầu để chặn tấn công hạ cấp (downgrade). Timestamp chỉ được tin sau khi chữ ký khớp, vì trước đó nó là chữ ai cũng ghi được.

Chuỗi được ký phải là đúng các byte đã nhận. Nhiều framework parse body thành đối tượng trước khi code của mình chạy, và cách dễ viết nhất là serialize đối tượng đó lại rồi tính HMAC. Bản serialize lại chỉ trùng byte gốc khi khớp mọi chi tiết: khoảng trắng, thứ tự khóa, cách escape chữ có dấu. serialize_lai.py đo việc này trên 1.000 webhook hợp lệ, 70% có nội dung có dấu như Thanh toán đơn 10123 (tỷ lệ minh họa). Cổng gửi JSON gọn, rồi đổi sang JSON thụt lề 2 dấu cách.

import json, random
from chu_ky import ky, kiem

random.seed(42)
KHOA, T = random.randbytes(32), 1790825400

def su_kien(i):
    noi_dung = random.choices([f"Thanh toán đơn {i}", f"DH{i}"], weights=[7, 3])[0]
    return {"id": f"evt_{i}", "loai": "thanh_toan.thanh_cong", "tao_luc": T,
            "du_lieu": {"ma_gd": f"GD{i}", "ma_don": i, "so_tien": 18990000,
                        "tien_te": "VND", "noi_dung": noi_dung}}

lai = lambda b, **kw: json.dumps(json.loads(b), **kw).encode()   # parse rồi serialize lại
GON = {"separators": (",", ":")}
CACH = {  # cách BanHang lấy chuỗi để tính HMAC
    "Byte thô, không parse": lambda b: b,
    "json.dumps mặc định": lambda b: lai(b),
    "Gọn, escape Unicode": lambda b: lai(b, **GON),
    "Gọn, UTF-8, sắp khóa": lambda b: lai(b, **GON, ensure_ascii=False, sort_keys=True),
    "Gọn, UTF-8, giữ thứ tự": lambda b: lai(b, **GON, ensure_ascii=False),
}
cac_su_kien = [su_kien(10000 + i) for i in range(1000)]
gon = [json.dumps(s, ensure_ascii=False, separators=(",", ":")).encode() for s in cac_su_kien]
thut_le = [json.dumps(s, ensure_ascii=False, indent=2).encode() for s in cac_su_kien]
print("webhook có chữ có dấu:", sum(not s["du_lieu"]["noi_dung"].isascii() for s in cac_su_kien))
print(f"{'cách kiểm':<24} {'cổng gửi gọn':>13} {'cổng gửi thụt lề':>17}")
for ten, lam_lai in CACH.items():
    tu_choi = [sum(kiem(ky(b, [KHOA], T), lam_lai(b), [KHOA], T) is not None for b in dot)
               for dot in (gon, thut_le)]
    print(f"{ten:<24} {tu_choi[0]:>13} {tu_choi[1]:>17}")

Script in số webhook có chữ có dấu (691) rồi một bảng năm dòng; biểu đồ dưới vẽ đúng bảng đó.

Chỉ kiểm trên byte thô giữ được cả 1.000 webhook hợp lệ ở cả hai định dạng của cổng

Cổng gửi JSON gọnCổng gửi JSON thụt lề
Byte thô, không parse0 webhook0 webhookjson.dumps mặc định1.000 webhook1.000 webhookGọn, escape Unicode691 webhook1.000 webhookGọn, UTF-8, sắp khóa1.000 webhook1.000 webhookGọn, UTF-8, giữ thứ tự0 webhook1.000 webhook
Số webhook hợp lệ bị từ chối trên 1.000, trong đó 691 có chữ có dấu (tỷ lệ minh họa). serialize_lai.py, Python 3.14.3, hạt giống 42.
Bảng số liệu
Cổng gửi JSON gọnCổng gửi JSON thụt lề
Byte thô, không parse0 webhook0 webhook
json.dumps mặc định1.000 webhook1.000 webhook
Gọn, escape Unicode691 webhook1.000 webhook
Gọn, UTF-8, sắp khóa1.000 webhook1.000 webhook
Gọn, UTF-8, giữ thứ tự0 webhook1.000 webhook

json.dumps mặc định chèn dấu cách sau , và :, nên hỏng cả 1.000. Bản gọn nhưng giữ ensure_ascii=True đổi ệ thành \u1ec7: hỏng đúng 691 webhook có dấu, qua 309 webhook không dấu. Lỗi kiểu này chỉ hiện ở production, vì dữ liệu thử thường không dấu. Sắp khóa hỏng cả 1.000 vì cổng không gửi khóa theo thứ tự chữ cái. Bản chép đúng định dạng của cổng qua được cả 1.000, rồi hỏng cả 1.000 khi cổng đổi sang thụt lề. Tài liệu Stripe ghi đúng các nguyên nhân này: framework thêm bớt khoảng trắng, đổi thứ tự khóa hay đổi encoding đều làm việc kiểm chữ ký thất bại.

Mặc định của framework làm lỗi này dễ xảy ra. DefaultJSONProvider của Flask, dùng cho app.json.dumps, đặt sort_keys = True và ensure_ascii = True. System.Text.Json của .NET mặc định escape mọi ký tự ngoài ASCII; chạy thử trên .NET 10.0.12, Thanh toán đơn thành Thanh to\u00E1n \u0111\u01A1n, hex chữ hoa, còn Python viết \u00e1. Trong API của BanHang, endpoint webhook chỉ nhận HttpRequest, chép Request.Body ra mảng byte, kiểm chữ ký trên mảng đó, rồi mới deserialize.

3. So sánh hằng thời gian

Phép == của CPython trên str hay bytes dừng ở chỗ khác nhau đầu tiên, như đã phân tích ở Lưu mật khẩu đúng cách. Với webhook, rò rỉ có hình dạng riêng: kẻ tấn công tự chọn body, ví dụ đơn 10088, và chỉ cần 64 ký tự hex của chữ ký. Đoán mù là 16^64 = 2^256 khả năng. Nếu thời gian trả lời cho biết bao nhiêu ký tự đầu đã khớp, họ dò từng vị trí: tối đa 64 × 16 = 1.024 lần thử, trung bình khoảng 64 × 8,5 = 544 lần.

Thực tế mỗi lần thử phải đo lặp lại để lọc nhiễu mạng. Crosby, Wallach và Riedi (2009) phân biệt được chênh lệch thời gian xử lý 200 ns trong mạng LAN và 30 µs qua Internet, với 1.000 phép đo cho mỗi lần so sánh. Chênh lệch của == giữa sai ở ký tự đầu và sai ở ký tự cuối, trên 64 ký tự, nhỏ hơn mức đó nhiều. Đo bằng timeit trên laptop viết bài (3 lần chạy), chênh lệch này còn nhỏ hơn dao động giữa các lần chạy, vốn cỡ vài chục nano giây, nên bài không đưa con số riêng. Khai thác qua mạng vì thế khó, nhưng không có lý do đánh cược. hmac.compare_digest không dừng sớm theo nội dung, từ Python 3.10 dùng CRYPTO_memcmp của OpenSSL khi có, và theo tài liệu chỉ có thể lộ kiểu và độ dài đầu vào. Chi phí của nó không đáng kể so với một request HTTP.

4. Chống phát lại: timestamp và mã sự kiện

Chữ ký đúng không có nghĩa là request mới. Một webhook thật chép được từ log, nơi ghi cả header lẫn body, gửi lại nguyên văn vẫn có chữ ký đúng. Với đơn hàng, báo "đã thanh toán" lần hai cho đơn đã thanh toán thường vô hại. Với ví BHPay, khách nạp bằng chuyển khoản vào tài khoản định danh của ví, tiền đến lúc nào cộng lúc đó: phát lại webhook nạp 2.000.000 đ là cộng thêm 2.000.000 đ. Cổng cũng tự gửi lại. Stripe thử lại tới ba ngày ở live mode khi không nhận được 2xx, và mỗi lần gửi có timestamp và chữ ký mới.

Lớp thứ nhất là timestamp. Nó nằm trong chuỗi được ký nên không sửa được. kiem từ chối khi lệch quá 300 giây so với đồng hồ của BanHang, bằng mặc định 5 phút của thư viện Stripe; đồng hồ hai bên đồng bộ bằng NTP. Lớp này chặn webhook chép được từ hôm qua. Nó không chặn webhook chép được 2 phút trước, và không chặn lần cổng tự gửi lại vì lần đó có timestamp mới. Lớp thứ hai là mã sự kiện, ghi vào bảng có khóa chính trước khi xử lý. Bảng mới trong BanHang, BHPay dùng nguyên dạng cho webhook nạp ví:

CREATE TABLE dbo.WebhookNhan (
    MaSuKien varchar(64) COLLATE Latin1_General_100_BIN2 NOT NULL
        CONSTRAINT PK_WebhookNhan PRIMARY KEY CLUSTERED,
    Loai varchar(50) NOT NULL,
    Body varbinary(max) NOT NULL,      -- byte thô đã kiểm chữ ký
    NhanLuc datetime2(3) NOT NULL
        CONSTRAINT DF_WebhookNhan_NhanLuc DEFAULT SYSUTCDATETIME(),
    XuLyLuc datetime2(3) NULL          -- NULL: chờ xử lý nền
);
INSERT dbo.WebhookNhan (MaSuKien, Loai, Body) VALUES ('evt_N1', 'nap_vi', 0x7B7D);
INSERT dbo.WebhookNhan (MaSuKien, Loai, Body) VALUES ('evt_n1', 'nap_vi', 0x7B7D);
INSERT dbo.WebhookNhan (MaSuKien, Loai, Body) VALUES ('evt_N1', 'nap_vi', 0x7B7D);
-- Hai lệnh đầu: 1 dòng mỗi lệnh. Lệnh thứ ba: Msg 2627, Violation of PRIMARY KEY
-- constraint 'PK_WebhookNhan'. ... The duplicate key value is (evt_N1).

Kết quả trong chú thích chạy trên SQL Server 2019 CU27 (LocalDB 15.0.4382.1), database collation Vietnamese_100_CI_AS. MaSuKien dùng Latin1_General_100_BIN2 vì mã sự kiện phân biệt hoa thường. Với collation của database, evt_n1 bị coi là trùng evt_N1 (đã thử, cũng lỗi 2627), và một sự kiện thật bị bỏ qua. Lỗi 2627 là tín hiệu "đã nhận": endpoint trả 200 và không xử lý lại. Body giữ byte thô để kiểm lại chữ ký khi điều tra.

Thời gian giữ mã phải dài hơn mọi đường gửi lại của cổng. Stripe tự thử lại ba ngày, cho gửi lại thủ công cùng sự kiện tới 15 ngày từ Dashboard và 30 ngày bằng Stripe CLI. BanHang giữ 30 ngày, khoảng 13.000 × 30 = 390.000 dòng (ước lượng, coi mỗi đơn một webhook). Kẻ tấn công chỉ phát lại được trong 5 phút nhờ timestamp, nên bảng không phải giữ mãi.

Mã sự kiện phải nằm trong phần được ký. Theo tài liệu GitHub, chữ ký chỉ tính trên body, còn mã của mỗi lần giao nằm ở header X-GitHub-Delivery. Header đó không được chữ ký bảo vệ, nên người phát lại tự đổi được giá trị. Cổng đối tác của BanHang đặt id trong body. Bảng dưới so ba lược đồ theo tài liệu đọc ngày 2026-10-01; cả ba dùng HMAC-SHA256, chữ ký dạng hex.

Stripe GitHub Cổng đối tác của BanHang
Header Stripe-Signature: t=…,v1=… X-Hub-Signature-256: sha256=… X-Signature: t=…,v1=…
Chuỗi được ký t + . + body body t + . + body
Chống phát lại Timestamp được ký, thư viện mặc định 5 phút; khử trùng theo mã sự kiện Header X-GitHub-Delivery, không nằm trong phần được ký Timestamp 300 s, id trong body
Xoay khóa Giữ khóa cũ tới 24 giờ, mỗi khóa một v1 Hai trang đã đọc không mô tả Hai v1 trong thời gian chuyển

5. Đối chiếu với dữ liệu của mình, trả 2xx trước khi xử lý

Chữ ký chứng minh người gửi giữ khóa, không chứng minh nội dung là điều BanHang đang chờ. Đơn 10091 giá 18.990.000 đ, khách chuyển khoản thủ công với nội dung có mã đơn nhưng chỉ chuyển 1.000.000 đ. Cổng nhận đúng 1.000.000 đ và gửi một webhook thật, chữ ký đúng. Endpoint chỉ kiểm chữ ký sẽ chuyển đơn sang Đã thanh toán.

Bước xử lý vì thế so với chính dữ liệu của BanHang. Mã đơn phải tồn tại, TrangThai = 1 (Mới), và TongTien bằng đúng số tiền trong webhook. So bằng decimal: json.loads đọc 18990000.00 thành float, nên đọc với parse_float=decimal.Decimal. Trạng thái chỉ đi tới, từ 1 sang 2: Stripe không bảo đảm thứ tự giao sự kiện, và một sự kiện thất bại đến sau sự kiện thành công không được kéo đơn lùi lại. Lệch thì không đổi trạng thái, đánh dấu chờ đối soát, và vẫn trả 200, vì trả 4xx cho webhook có chữ ký đúng chỉ làm cổng gửi lại đúng nội dung đó.

Khi nghi ngờ, hỏi lại cổng: gọi API truy vấn giao dịch theo ma_gd bằng API key riêng, qua HTTPS tới tên miền của cổng, và tin kết quả đó hơn webhook. Đường này không dùng khóa webhook, nên vẫn đứng được khi khóa webhook lộ. Nghi ngờ gồm số tiền lệch, đơn lạ, đơn đã hủy, hoặc giá trị lớn. Với khoảng 13.000 đơn mỗi ngày, BanHang đủ sức hỏi lại mọi sự kiện thành công trước khi kho xuất hàng: trung bình 13.000 / 86.400 ≈ 0,15 lần gọi mỗi giây.

Đối chiếu và hỏi lại không chạy trong lúc cổng chờ. GitHub khuyên trả 2XX trong 10 giây. Stripe yêu cầu trả 2xx trước mọi xử lý phức tạp có thể gây timeout, và ghi timeout là một lần giao thất bại, tức sẽ có lần gửi lại. Endpoint của BanHang chỉ kiểm chữ ký và timestamp, chèn vào dbo.WebhookNhan, commit, rồi trả 200. Chèn mã trước rồi mới làm là mẫu giành khóa bằng INSERT ở Idempotency key, với cổng ở vai client gửi lại. Tiến trình nền đọc các dòng XuLyLuc IS NULL, đối chiếu, đổi trạng thái đơn và ghi XuLyLuc trong cùng một giao dịch, theo mẫu consumer idempotent ở Bài toán hai vị tướng.

6. Xoay vòng khóa bí mật

RFC 2104 coi việc đổi khóa định kỳ là thực hành nền tảng để giới hạn thiệt hại khi khóa lộ. Khóa còn phải đổi ngay khi nghi lộ, như khi file cấu hình ở đầu bài lên repo công khai. Một khóa không đổi được ở hai nơi cùng lúc. Giữa lúc cổng đã ký bằng khóa mới và lúc BanHang triển khai xong, mọi webhook bị từ chối và nằm chờ cổng gửi lại. Cách làm: trong thời gian chuyển, BanHang giữ cả hai khóa và nhận chữ ký khớp một trong hai. xoay_khoa.py thử ba giai đoạn phía cổng, mỗi giai đoạn một dòng, với ba cấu hình phía BanHang theo thứ tự [cũ], [mới, cũ], [mới]:

import secrets
from chu_ky import ky, kiem

cu, moi = secrets.token_bytes(32), secrets.token_bytes(32)
body, t = b'{"id":"evt_R1","loai":"thanh_toan.thanh_cong"}', 1790825400
for ten, cong_ky in [("cổng ký khóa cũ", [cu]), ("cổng ký cả hai", [cu, moi]),
                     ("cổng ký khóa mới", [moi])]:
    header = ky(body, cong_ky, t)
    ket_qua = [kiem(header, body, nhan, t) or "nhận" for nhan in ([cu], [moi, cu], [moi])]
    print(f"{ten:<17}", ket_qua)
cổng ký khóa cũ   ['nhận', 'nhận', 'chữ ký không khớp']
cổng ký cả hai    ['nhận', 'nhận', 'nhận']
cổng ký khóa mới  ['chữ ký không khớp', 'nhận', 'nhận']

Cấu hình [mới, cũ] nhận cả ba giai đoạn. Stripe lo phần phía cổng: khi roll secret, có thể giữ khóa cũ thêm tối đa 24 giờ, và trong thời gian đó tạo một chữ ký cho mỗi khóa, tức header mang hai v1. Với cổng chỉ ký bằng một khóa mỗi lúc, thứ tự là: BanHang triển khai [mới, cũ], đổi khóa ở cổng, rồi mới gỡ khóa cũ.

7. Ghép các lớp và đếm kịch bản lọt

thu_nghiem.py gom các lớp vào một hàm xu_ly, bật lần lượt từ 0 đến 5 lớp, và chạy bảy kịch bản trên một database mới cho mỗi cấu hình. Trong endpoint thật, do_POST đưa header và byte thô vào hàm này. Ở đây gọi thẳng, với đồng hồ giả để thử "sau 1 giờ" mà không phải chờ. Trước các kịch bản, hai webhook thật của cổng, thanh toán đơn 10089 và nạp ví BH-1234, phải được nhận ở mọi cấu hình; assert kiểm điều đó. Hàm xử lý đồng bộ cho gọn, còn trong thiết kế ở mục 5, phần sau INSERT chạy nền. Script in mỗi cấu hình một dòng, như +timestamp lọt [0, 0, 1, 1, 0, 1, 1], kịch bản theo thứ tự trong KICH_BAN; biểu đồ sau code xếp sáu dòng đó thành bảng.

import json, sqlite3
from chu_ky import ky, kiem

KHOA, T0 = bytes(range(32)), 1790825400     # T0: 10:30 ngày 2026-10-01, giờ Việt Nam
CONG = {"GD1": (10089, 590000), "GD2": ("BH-1234", 2000000), "GD3": (10091, 1000000)}

def xu_ly(lop, header, body, bay_gio):       # lop: số lớp đang bật, 0 đến 5
    if lop >= 1 and kiem(header, body, [KHOA], bay_gio, 300 if lop >= 2 else None):
        return 400                           # chữ ký sai hoặc quá hạn
    sk = json.loads(body)
    d, nap_vi = sk["du_lieu"], sk["loai"] == "nap_vi.thanh_cong"
    ma = d["ma_vi"] if nap_vi else d["ma_don"]
    with DB:
        if lop >= 3 and not DB.execute("INSERT OR IGNORE INTO su_kien VALUES (?)",
                                       (sk["id"],)).rowcount:
            return 200                       # đã nhận: báo xong, không làm lại
        don = DB.execute("SELECT so_tien, trang_thai FROM don WHERE ma_don = ?",
                         (ma,)).fetchone()
        if lop >= 4 and not nap_vi and don != (d["so_tien"], "moi"):
            return 200                       # lệch: giữ lại chờ đối soát
        if lop >= 5 and CONG.get(d["ma_gd"]) != (ma, d["so_tien"]):
            return 200                       # API truy vấn của cổng không xác nhận
        if nap_vi:                           # tiền vào tài khoản định danh của ví
            DB.execute("UPDATE vi SET so_du = so_du + ? WHERE ma_vi = ?", (d["so_tien"], ma))
        else:                                # kho nghe trạng thái này để xuất hàng
            DB.execute("UPDATE don SET trang_thai = 'da_thanh_toan' WHERE ma_don = ?", (ma,))
    return 200

def webhook(ma, t, **du_lieu):               # cổng ký, hoặc kẻ đã lấy được khóa
    loai = "nap_vi.thanh_cong" if "ma_vi" in du_lieu else "thanh_toan.thanh_cong"
    body = json.dumps({"id": ma, "loai": loai, "du_lieu": du_lieu},
                      separators=(",", ":")).encode()
    return ky(body, [KHOA], t), body

def gia_tri(x):                              # số dư ví, hoặc trạng thái đơn
    if x == "BH-1234":
        return DB.execute("SELECT so_du FROM vi WHERE ma_vi = ?", (x,)).fetchone()[0]
    return DB.execute("SELECT trang_thai FROM don WHERE ma_don = ?", (x,)).fetchone()[0]

NAP = dict(ma_gd="GD2", ma_vi="BH-1234", so_tien=2000000)
nap = webhook("evt_N1", T0, **NAP)
tt = webhook("evt_P1", T0, ma_gd="GD1", ma_don=10089, so_tien=590000)
gia = webhook("evt_X1", T0, ma_gd="GD9", ma_don=10088, so_tien=18990000)[1]
thieu = webhook("evt_P3", T0, ma_gd="GD3", ma_don=10091, so_tien=1000000)
lo_khoa = webhook("evt_X2", T0, ma_gd="GD8", ma_don=10092, so_tien=12990000)
KICH_BAN = {  # tên: (header, body, giờ BanHang nhận, đơn hoặc ví bị hại)
    "Webhook giả, không chữ ký": ("", gia, T0, 10088),
    "Đổi mã đơn trong webhook thật": (tt[0], tt[1].replace(b":10089", b":10090"), T0, 10090),
    "Cổng tự gửi lại nạp ví": (*webhook("evt_N1", T0 + 60, **NAP), T0 + 60, "BH-1234"),
    "Phát lại nạp ví sau 2 phút": (*nap, T0 + 120, "BH-1234"),
    "Phát lại nạp ví sau 1 giờ": (*nap, T0 + 3600, "BH-1234"),
    "Chuyển khoản thiếu tiền": (*thieu, T0, 10091),
    "Khóa ký bị lộ": (*lo_khoa, T0, 10092),
}
for lop, ten_lop in enumerate(["Chỉ JSON", "+HMAC", "+timestamp", "+mã sự kiện",
                               "+đối chiếu", "+hỏi cổng"]):
    DB = sqlite3.connect(":memory:")
    DB.executescript("""
        CREATE TABLE su_kien (id TEXT PRIMARY KEY);
        CREATE TABLE vi (ma_vi TEXT PRIMARY KEY, so_du INTEGER);
        CREATE TABLE don (ma_don INTEGER PRIMARY KEY, so_tien INTEGER, trang_thai TEXT);
        INSERT INTO vi VALUES ('BH-1234', 0);
        INSERT INTO don VALUES (10088, 18990000, 'moi'), (10089, 590000, 'moi'),
            (10090, 24490000, 'moi'), (10091, 18990000, 'moi'), (10092, 12990000, 'moi');""")
    xu_ly(lop, *nap, T0), xu_ly(lop, *tt, T0)          # hai webhook thật của cổng
    assert gia_tri(10089) == "da_thanh_toan" and gia_tri("BH-1234") == 2000000
    lot = []
    for ten, (header, body, gio, nan_nhan) in KICH_BAN.items():
        truoc = gia_tri(nan_nhan)
        xu_ly(lop, header, body, gio)
        lot.append(int(gia_tri(nan_nhan) != truoc))
    print(f"{ten_lop:<12} lọt {lot}")

Mỗi lớp chặn một nhóm kịch bản, và chỉ khi bật đủ năm lớp mới không còn ô nào lọt

Chỉ JSON+HMAC+timestamp+mã sự kiện+đối chiếu+hỏi cổngWebhook giả, không chữ ký100000Đổi mã đơn trong webhook thật100000Cổng tự gửi lại nạp ví111000Phát lại nạp ví sau 2 phút111000Phát lại nạp ví sau 1 giờ110000Chuyển khoản thiếu tiền111100Khóa ký bị lộ111110
1 = lọt (đơn sang Đã thanh toán hoặc ví được cộng thêm), 0 = bị chặn. Mỗi cột bật thêm một lớp so với cột bên trái. thu_nghiem.py, Python 3.14.3.
Bảng số liệu
Chỉ JSON+HMAC+timestamp+mã sự kiện+đối chiếu+hỏi cổng
Webhook giả, không chữ ký100000
Đổi mã đơn trong webhook thật100000
Cổng tự gửi lại nạp ví111000
Phát lại nạp ví sau 2 phút111000
Phát lại nạp ví sau 1 giờ110000
Chuyển khoản thiếu tiền111100
Khóa ký bị lộ111110

Chữ ký chặn hai kịch bản không có khóa: webhook giả, và webhook thật bị đổi mã đơn từ 10089 (590.000 đ) sang 10090 (24.490.000 đ). Timestamp chặn phát lại sau 1 giờ, nhưng để lọt phát lại sau 2 phút và lần cổng tự gửi lại; mã sự kiện chặn nốt hai kịch bản đó. Đối chiếu đơn chặn chuyển khoản thiếu tiền. Webhook ký bằng khóa lộ qua mọi kiểm tra trên nội dung: chữ ký đúng, mã sự kiện mới, số tiền khớp đơn 10092. Chỉ hỏi lại cổng mới chặn được, vì cổng không có giao dịch GD8. Không cấu hình nào từ chối nhầm hai webhook thật.

Những chỗ hay hiểu sai

  • "Chỉ nhận request từ IP của cổng là đủ." Sau load balancer, ứng dụng chỉ thấy IP của load balancer và phải đọc X-Forwarded-For; theo MDN, phần header không do proxy tin cậy thêm vào có thể bị giả. Danh sách IP còn đổi theo thời gian. Stripe khuyên dùng danh sách IP cùng với chữ ký, không thay chữ ký.
  • "Có HTTPS thì không ai giả được." TLS cho cổng biết đang nói chuyện với đúng BanHang, không cho BanHang biết ai đang gửi. Ai cũng mở được kết nối HTTPS tới endpoint và gửi POST.
  • "URL dài, khó đoán thay được chữ ký." URL nằm trong log truy cập, log proxy và file cấu hình, như ở đầu bài. Lộ một lần là giả được mọi webhook cho đến khi đổi URL, và URL không bảo vệ body hay chống phát lại.

Đọc tiếp

Nguồn

Đọc tiếp