签名有效的 Webhook 到达了两次
目标
把原始正文的签名、时间窗口和事件去重,连成同一条 Webhook 处理路径。
为什么重要
支付服务商没有收到响应,又把同一个事件发了一遍。服务器看到签名无误,就认为是正常请求,于是把销售额又加了一次。签名只能证明是谁发来的,并不能证明这是第一次处理的请求。允许重新发送的时间窗口,以及已保存的事件 id,必须分别检查。
步骤
- 在
/root/work/idem-webhook-lab/service.py中,signed_bytes(timestamp, body) 只接受(不含 bool 的)int 类型的 timestamp 和 bytes 类型的 body,并返回 str(timestamp).encode()+b'.'+body。类型有误时抛出 ValueError。
先做一次准备。已有的文件不会被覆盖。
mkdir -p /root/work/idem-webhook-lab
test -e /root/work/idem-webhook-lab/service.py || cp /opt/fixtures/ten_labs/idem-webhook-lab/service.py /root/work/idem-webhook-lab/service.py
cd /root/work/idem-webhook-lab
-
在
/root/work/idem-webhook-lab/service.py中,signature(secret, timestamp, body) 用 bytes 类型的 secret,对 signed_bytes 应用 HMAC-SHA256,得到 hex 字符串。 -
在
/root/work/idem-webhook-lab/service.py中,verify(secret, timestamp, body, supplied) 仅在 supplied 是 str,且用 compare_digest 与计算出的签名相等时返回 True,否则返回 False。 -
在
/root/work/idem-webhook-lab/service.py中,fresh(timestamp, now, tolerance=300) 在 timestamp 和 now 都是(不含 bool 的)int,且 abs(now-timestamp)<=tolerance 时返回 True,其余返回 False。tolerance 是由调用方给出的正 int。 -
在
/root/work/idem-webhook-lab/service.py中,init_db(path) 创建 inbox(id TEXT PRIMARY KEY, fingerprint TEXT NOT NULL) 和 total(id INTEGER PRIMARY KEY, amount INTEGER NOT NULL),并在 total 中不重复地插入 id=1,amount=0。 -
在
/root/work/idem-webhook-lab/service.py中,apply(path, event, fault=lambda:None) 校验 event 的 id 是非空 str,amount 是(不含 bool 的)正 int。用规范化的 JSON 对 id 和 amount 计算指纹。相同的 id 与指纹返回 False,指纹不同抛出 ValueError,新事件则在依次执行插入 inbox → fault() → 合计增加之后返回 True。 -
在
/root/work/idem-webhook-lab/service.py中,total(path) 返回 total 表中 id=1 那一行的 amount 整数。 -
在
/root/work/idem-webhook-lab/service.py中,create_app(path, secret, clock) 在 POST /webhook 中读取原始 body、X-Timestamp 和 X-Signature。时间格式、时间窗口、签名校验失败返回 401,JSON 解析失败返回 400,apply 抛出的 ValueError 返回 409。正常情况返回 200 {accepted:True, duplicate:首次处理时为 False}。
参考
- 无需联网和安装软件包,在现有的 lab-dev 环境中进行。
- 每个步骤都在 45 秒的评分预算内运行。不要加入真实的 sleep 或网络调用。
- 评分会重新导入提交的模块,并用独立的输入和临时 DB 检查。不要直接返回预期值的常量,而要实现契约。
- FastAPI 官方文档 · pytest 官方文档 · Python sqlite3
- 局限:这种签名格式是教学用的协议,并不能取代真实支付服务商的规范。只使用学习用的 secret。服务器时钟是否可靠、密钥轮换、允许的正文大小以及永久保存的期限,是另外的运维课题。去重适用于相同的事件 id 加相同的正文;如果用不同的正文复用同一个 id,就是冲突。
保留要签名的原始内容
在 /root/work/idem-webhook-lab/service.py 中,signed_bytes(timestamp, body) 只接受(不含 bool 的)int 类型的 timestamp 和 bytes 类型的 body,并返回 str(timestamp).encode()+b'.'+body。类型有误时抛出 ValueError。
先做一次准备。已有的文件不会被覆盖。
mkdir -p /root/work/idem-webhook-lab
test -e /root/work/idem-webhook-lab/service.py || cp /opt/fixtures/ten_labs/idem-webhook-lab/service.py /root/work/idem-webhook-lab/service.py
cd /root/work/idem-webhook-lab
把原始 body 解析成 JSON 再重新生成,会使签名对象发生变化。
保存后用 bash /opt/lab/checks/idem-webhook-lab/01-contract.sh 确认。
计算 HMAC
在 /root/work/idem-webhook-lab/service.py 中,signature(secret, timestamp, body) 用 bytes 类型的 secret,对 signed_bytes 应用 HMAC-SHA256,得到 hex 字符串。
使用标准的 HMAC,而不是把密钥拼接到普通哈希后面的做法。
保存后用 bash /opt/lab/checks/idem-webhook-lab/02-contract.sh 确认。
拒绝错误的签名
在 /root/work/idem-webhook-lab/service.py 中,verify(secret, timestamp, body, supplied) 仅在 supplied 是 str,且用 compare_digest 与计算出的签名相等时返回 True,否则返回 False。
“有没有签名”与“签名对不对”是两项不同的检查。
保存后用 bash /opt/lab/checks/idem-webhook-lab/03-contract.sh 确认。
限制过去和未来的重放
在 /root/work/idem-webhook-lab/service.py 中,fresh(timestamp, now, tolerance=300) 在 timestamp 和 now 都是(不含 bool 的)int,且 abs(now-timestamp)<=tolerance 时返回 True,其余返回 False。tolerance 是由调用方给出的正 int。
如果无条件允许未来的时间戳,攻击者就能延长有效期。
保存后用 bash /opt/lab/checks/idem-webhook-lab/04-contract.sh 确认。
为同时保存事件与效果做准备
在 /root/work/idem-webhook-lab/service.py 中,init_db(path) 创建 inbox(id TEXT PRIMARY KEY, fingerprint TEXT NOT NULL) 和 total(id INTEGER PRIMARY KEY, amount INTEGER NOT NULL),并在 total 中不重复地插入 id=1,amount=0。
重复事件的记录与业务合计必须在同一个 DB 事务里。
保存后用 bash /opt/lab/checks/idem-webhook-lab/05-contract.sh 确认。
回滚记录与销售额之间的失败
在 /root/work/idem-webhook-lab/service.py 中,apply(path, event, fault=lambda:None) 校验 event 的 id 是非空 str,amount 是(不含 bool 的)正 int。用规范化的 JSON 对 id 和 amount 计算指纹。相同的 id 与指纹返回 False,指纹不同抛出 ValueError,新事件则在依次执行插入 inbox → fault() → 合计增加之后返回 True。
fault 中一旦抛出异常,inbox 和合计都不能留下。
保存后用 bash /opt/lab/checks/idem-webhook-lab/06-contract.sh 确认。
用单独的连接读取合计
在 /root/work/idem-webhook-lab/service.py 中,total(path) 返回 total 表中 id=1 那一行的 amount 整数。
不看回调被执行了几次,而是确认 DB 里实际留下的业务效果。
保存后用 bash /opt/lab/checks/idem-webhook-lab/07-contract.sh 确认。
处理真实的 Webhook 请求
在 /root/work/idem-webhook-lab/service.py 中,create_app(path, secret, clock) 在 POST /webhook 中读取原始 body、X-Timestamp 和 X-Signature。时间格式、时间窗口、签名校验失败返回 401,JSON 解析失败返回 400,apply 抛出的 ValueError 返回 409。正常情况返回 200 {accepted:True, duplicate:首次处理时为 False}。
先验证签名,再读取 JSON;重复的请求也以正常的确认响应返回。
保存后用 bash /opt/lab/checks/idem-webhook-lab/08-contract.sh 确认。