适用场景

支付、订单、代码托管或消息平台通过 Webhook 主动回调业务系统。接口已经校验 HMAC 签名,但偶尔仍出现同一事件被重复执行,甚至攻击者截获一条合法请求后,可以在数小时后原样重放。

本文以 Python 3.11、FastAPI 和 Redis 为例,实现一套可直接落地的接收端:对原始请求体进行 HMAC-SHA256 验签,限制时间窗口,以事件 ID 做原子去重,并把“重复投递”和“恶意重放”纳入可观测范围。示例协议可按第三方平台的字段名调整。

现象描述

常见现场表现包括:

  • Webhook 日志显示每次请求的签名都合法,但同一订单被更新多次;
  • 上游因超时重试,短时间内重复发送同一事件;
  • 攻击者复制完整请求后,能够在另一个时间再次调用接口;
  • 代码先执行业务,再写“已处理”标记,并发请求同时穿透;
  • 验签时重新序列化 JSON,导致合法请求偶发验签失败;
  • 为了排查问题记录了签名密钥、完整请求体或用户隐私数据,形成新的安全风险。

关键认知是:签名只能证明“请求内容由持有密钥的一方生成且未被篡改”,不能天然证明“这是第一次收到该请求”。防重放必须另外校验时间戳、唯一事件 ID 和处理状态。

威胁模型与协议约定

接收端约定上游发送以下请求头:

X-Webhook-Timestamp: 1788062400
X-Webhook-Event-Id: evt_01HXYZ...
X-Webhook-Signature: v1=2f4c...

签名原文严格定义为:

<timestamp>.<event_id>.<原始请求体字节>

服务端使用共享密钥计算:

HMAC-SHA256(secret, signed_payload)

把时间戳和事件 ID 纳入签名非常重要。否则攻击者可以替换未签名的时间戳,绕过时间窗口;也可以替换事件 ID,绕过去重键。

本方案防护以下风险:

  • 请求体或关键请求头被篡改;
  • 合法请求在允许时间窗口之外被重放;
  • 同一事件在多实例、并发场景下被重复消费;
  • 密钥轮换期间新旧发送方切换造成大面积验签失败。

它不替代 HTTPS、业务权限校验和下游幂等。若攻击者已经获得共享密钥,就能自行构造合法签名,应立即轮换密钥并调查泄露范围。

实现思路

安全处理顺序应固定为:

  1. 限制请求体大小并读取原始字节;
  2. 校验请求头是否存在、格式是否合法;
  3. 检查时间戳与服务器当前时间的偏差;
  4. 使用原始字节计算 HMAC,并做常量时间比较;
  5. 使用事件 ID 在 Redis 中原子占位;
  6. 解析 JSON 并校验事件类型、对象 ID 等业务字段;
  7. 执行业务逻辑,并依靠数据库唯一约束或状态机实现最终幂等;
  8. 快速返回 2xx,耗时任务交给可靠队列。

不要在验签前解析并重新序列化 JSON。空格、换行、键顺序或 Unicode 转义方式变化都会改变字节序列,使相同 JSON 语义产生不同摘要。

可直接使用的验签代码

创建 webhook_security.py

from __future__ import annotations

import hashlib
import hmac
import time
from dataclasses import dataclass


class WebhookVerificationError(ValueError):
    """表示 Webhook 请求未通过安全校验。"""


@dataclass(frozen=True)
class VerifiedWebhook:
    """保存通过验签的 Webhook 最小上下文。"""

    event_id: str
    timestamp: int


def verify_webhook(
    *,
    body: bytes,
    timestamp_text: str,
    event_id: str,
    signature_header: str,
    secrets: tuple[bytes, ...],
    now: int | None = None,
    tolerance_seconds: int = 300,
) -> VerifiedWebhook:
    """校验时间窗口、事件标识和 HMAC-SHA256 签名。"""
    if not event_id or len(event_id) > 128:
        raise WebhookVerificationError("事件 ID 缺失或长度非法")

    try:
        timestamp = int(timestamp_text)
    except ValueError as exc:
        raise WebhookVerificationError("时间戳格式非法") from exc

    current_time = int(time.time()) if now is None else now
    if abs(current_time - timestamp) > tolerance_seconds:
        raise WebhookVerificationError("请求时间戳超出允许窗口")

    if not signature_header.startswith("v1="):
        raise WebhookVerificationError("签名版本不受支持")
    supplied_digest = signature_header.removeprefix("v1=")
    if len(supplied_digest) != 64:
        raise WebhookVerificationError("签名长度非法")

    signed_payload = (
        timestamp_text.encode("ascii")
        + b"."
        + event_id.encode("utf-8")
        + b"."
        + body
    )

    # 轮换期同时接受新旧密钥,但日志中不得输出密钥或完整签名。
    is_valid = any(
        hmac.compare_digest(
            hmac.new(secret, signed_payload, hashlib.sha256).hexdigest(),
            supplied_digest,
        )
        for secret in secrets
    )
    if not is_valid:
        raise WebhookVerificationError("签名不匹配")

    return VerifiedWebhook(event_id=event_id, timestamp=timestamp)

关键点如下:

  • body 必须是网络读取到的原始字节;
  • hmac.compare_digest() 避免普通字符串比较引入可利用的时间差;
  • abs(current_time - timestamp) 同时拒绝过旧和明显来自未来的请求;
  • secrets 支持轮换期同时验证新旧密钥,稳定后应移除旧密钥;
  • 错误文案只描述失败类型,不返回期望签名或内部密钥信息。

FastAPI 接收端与 Redis 原子去重

安装依赖后,可把下面的路由接入现有应用。生产环境应从密钥管理系统注入 WEBHOOK_SECRETS,不要把密钥提交到仓库。

import json
import os

from fastapi import APIRouter, HTTPException, Request, Response, status
from redis.asyncio import Redis

from webhook_security import WebhookVerificationError, verify_webhook

router = APIRouter()
redis_client = Redis.from_url(
    os.environ["REDIS_URL"],
    encoding="utf-8",
    decode_responses=True,
)
webhook_secrets = tuple(
    item.encode("utf-8")
    for item in os.environ["WEBHOOK_SECRETS"].split(",")
    if item
)

MAX_BODY_BYTES = 1024 * 1024
DEDUPLICATION_TTL_SECONDS = 24 * 60 * 60


@router.post("/webhooks/payment")
async def receive_payment_webhook(request: Request) -> Response:
    """接收支付事件,并在安全校验后执行幂等处理。"""
    content_length = request.headers.get("content-length")
    if content_length and int(content_length) > MAX_BODY_BYTES:
        raise HTTPException(status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE)

    body = await request.body()
    if len(body) > MAX_BODY_BYTES:
        raise HTTPException(status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE)

    try:
        verified = verify_webhook(
            body=body,
            timestamp_text=request.headers["X-Webhook-Timestamp"],
            event_id=request.headers["X-Webhook-Event-Id"],
            signature_header=request.headers["X-Webhook-Signature"],
            secrets=webhook_secrets,
        )
    except (KeyError, WebhookVerificationError, UnicodeError) as exc:
        # 对外统一返回,详细原因只在脱敏后的内部日志中记录。
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="Webhook 校验失败",
        ) from exc

    deduplication_key = f"webhook:payment:{verified.event_id}"
    is_first_delivery = await redis_client.set(
        deduplication_key,
        "processing",
        ex=DEDUPLICATION_TTL_SECONDS,
        nx=True,
    )
    if not is_first_delivery:
        # 对已验签的重复投递返回 200,避免上游持续重试。
        return Response(status_code=status.HTTP_200_OK)

    try:
        event = json.loads(body)
        await process_payment_event(event, verified.event_id)
    except (json.JSONDecodeError, ValueError) as exc:
        # 无效载荷可删除占位,让上游修正后使用相同事件 ID 重试。
        await redis_client.delete(deduplication_key)
        raise HTTPException(
            status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
            detail="Webhook 载荷非法",
        ) from exc
    except Exception:
        # 临时故障释放占位,使上游重试能够重新处理;此处必须记录错误链。
        await redis_client.delete(deduplication_key)
        raise

    await redis_client.set(
        deduplication_key,
        "completed",
        ex=DEDUPLICATION_TTL_SECONDS,
    )
    return Response(status_code=status.HTTP_204_NO_CONTENT)

SET key value EX ttl NX 是一个原子操作:只有第一个请求能获得处理权。不要把 EXISTSSET 拆成两条命令,否则两个并发实例可能同时看到键不存在并执行业务。

Redis 去重用于快速挡住重复投递,但不能成为唯一防线。Redis 故障、键过期或运维误删后,事件仍可能再次进入业务。因此,扣款、发货、记账等关键写入必须以 provider + event_id 建立数据库唯一约束,并在同一个事务内写入事件记录和业务状态。

CREATE TABLE processed_webhook_event (
    provider VARCHAR(32) NOT NULL,
    event_id VARCHAR(128) NOT NULL,
    processed_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
    PRIMARY KEY (provider, event_id)
);

测试正常、边界与失败路径

验签逻辑应使用注入的 now,避免测试依赖真实时钟。以下 pytest 用例覆盖合法请求、篡改和过期重放:

import hashlib
import hmac

import pytest

from webhook_security import WebhookVerificationError, verify_webhook


def build_signature(secret: bytes, timestamp: str, event_id: str, body: bytes) -> str:
    """构造与发送方协议一致的测试签名。"""
    payload = timestamp.encode() + b"." + event_id.encode() + b"." + body
    return "v1=" + hmac.new(secret, payload, hashlib.sha256).hexdigest()


def test_verify_webhook_accepts_valid_request() -> None:
    """合法请求应返回已验证上下文。"""
    body = b'{"type":"payment.succeeded"}'
    signature = build_signature(b"secret", "1700000000", "evt_1", body)

    result = verify_webhook(
        body=body,
        timestamp_text="1700000000",
        event_id="evt_1",
        signature_header=signature,
        secrets=(b"secret",),
        now=1700000060,
    )

    assert result.event_id == "evt_1"


def test_verify_webhook_rejects_modified_body() -> None:
    """请求体被篡改后必须验签失败。"""
    signature = build_signature(b"secret", "1700000000", "evt_1", b"{}")

    with pytest.raises(WebhookVerificationError, match="签名不匹配"):
        verify_webhook(
            body=b'{"amount":9999}',
            timestamp_text="1700000000",
            event_id="evt_1",
            signature_header=signature,
            secrets=(b"secret",),
            now=1700000060,
        )


def test_verify_webhook_rejects_expired_replay() -> None:
    """超过时间窗口的合法签名也应被拒绝。"""
    body = b"{}"
    signature = build_signature(b"secret", "1700000000", "evt_1", body)

    with pytest.raises(WebhookVerificationError, match="超出允许窗口"):
        verify_webhook(
            body=body,
            timestamp_text="1700000000",
            event_id="evt_1",
            signature_header=signature,
            secrets=(b"secret",),
            now=1700000601,
            tolerance_seconds=300,
        )

接口层还应补充以下测试:缺失请求头返回 401、请求体超过上限返回 413、两个并发请求只有一个进入业务函数、业务临时失败后允许重试、数据库唯一约束阻止过期后的重复处理。

定位与观测实践

建议记录结构化字段,但不要记录密钥、完整签名、Cookie、Authorization 或原始敏感请求体:

message="Webhook 校验失败" provider="payment" event_id_hash="..." reason="timestamp_expired" source_ip="..."
message="Webhook 重复投递" provider="payment" event_id_hash="..." deduplication_state="completed"
message="Webhook 处理完成" provider="payment" event_type="payment.succeeded" duration_ms=37

event_id_hash 可使用服务端专用盐计算摘要,既能关联重复事件,又避免直接暴露外部标识。重点监控:

  • webhook_verification_failed_total{reason}:按失败原因统计验签拒绝;
  • webhook_duplicate_total{provider}:观察上游重试或重放异常;
  • webhook_processing_duration_seconds:处理耗时分布;
  • webhook_processing_failed_total{event_type}:业务处理失败;
  • 队列积压、Redis 错误率和数据库唯一键冲突数。

某来源短时间出现大量 signature_mismatchtimestamp_expired 时,应触发告警并结合网关限流。不要仅依赖来源 IP 白名单,因为云平台出口地址可能变化,代理链也可能被错误配置。

修复与上线步骤

  1. 先与上游确认签名原文、字符编码、摘要算法和多签名格式;
  2. 在测试环境保存不含敏感信息的固定向量,验证双方实现完全一致;
  3. 接入时间窗口和事件 ID 去重,初始只记录指标,不立即拦截;
  4. 观察服务器时钟偏差和上游投递延迟,再确定窗口,通常可从 5 分钟开始;
  5. 为关键业务表补充唯一约束或合法状态转换,验证最终幂等;
  6. 开启强制拦截,并对拒绝率、重复率和处理失败率告警;
  7. 演练密钥轮换:先部署新旧双验,再切换发送方,最后撤销旧密钥。

如果服务需要先返回 2xx 再异步处理,应在响应前把事件可靠写入数据库或持久化消息队列。仅把任务放进进程内存就返回成功,会在进程崩溃时永久丢失事件。

注意事项

  • 生产环境必须使用 HTTPS,并校验证书;HMAC 不提供传输机密性;
  • 明确请求体上限、请求超时和并发上限,避免验签接口成为拒绝服务入口;
  • 服务端时钟应使用可靠 NTP 同步,但不要通过无限放大时间窗口掩盖时钟问题;
  • 去重 TTL 至少覆盖上游最大重试周期,并结合数据保留与 Redis 容量评估;
  • 多租户场景的去重键必须包含租户或发送方,避免不同来源的事件 ID 冲突;
  • 不要对未验签请求返回“事件已存在”等内部状态,避免泄露可枚举信息;
  • 第三方若支持非对称签名,优先按其官方协议验证公钥签名,避免自行发明协议。

总结

Webhook 安全不是“算一次 HMAC”就结束。可靠的接收端需要同时满足内容完整性、时间新鲜度、并发去重和业务幂等:原始字节参与签名,时间戳与事件 ID 也必须被签名;Redis 使用原子 SET NX 抵挡并发重复;数据库唯一约束守住最终一致性;日志和指标则帮助区分正常重试、实现错误与恶意重放。

把这些边界一次设计清楚,才能避免出现“每次验签都成功,业务却重复执行”的隐蔽事故。