登录
推荐 文章 Go 技术 课程 下载 专题 AI
首页 >  Golang >  Go教程

Go Webhook 签名校验怎么防重放:时间窗、Nonce 与 HMAC 验证

来源:17golang原创

时间:2026-07-21 10:02:08 144浏览 收藏

Webhook的HMAC签名能证明请求内容没被篡改,却没法保证这条请求只会被处理一次。攻击者拿到一份合法请求后,在几秒到几分钟的时间窗口里原样重发,很可能导致订单状态被重复变更、积分重复发放、退款通知被多次执行这类异常。更稳妥的实现方式是把时间戳和Nonce一并放进签名基串,再通过服务端的短期去重记录拦截重复请求。

要点速览

  • 签名基串至少要绑定HTTP方法、请求路径、时间戳、Nonce和原始请求体。
  • 服务端先校验时间窗口合法性,再做HMAC比对,最后原子化消费Nonce。
  • 比较摘要使用 hmac.Equal,不要用普通字符串比较代替。
  • 业务逻辑层必须自带幂等键,Nonce只能防同一报文重放,不能替代业务层的去重判断。

先把“签名正确”和“请求新鲜”分开

接收端通常会收到四个和认证相关的字段:X-Hook-TimestampX-Hook-NonceX-Hook-Signature 和原始请求体。HMAC只负责证明“这些字节确实是持有正确密钥的一方生成的”,时间戳和Nonce才用来判断“它是否在允许的时间范围内出现过、有没有之前已经被处理过”。

字段作用接收端检查规则
Timestamp限制请求新鲜度和服务端当前时间的差值不超过5分钟
Nonce单次请求的唯一标识写入短期共享存储,重复出现直接拒绝
Signature保护所有关联字段和请求正文按相同基串重新计算HMAC-SHA256比对

先固定签名基串,避免两端各算各的

一套实用的基串拼接规则可以直接用下面的写法。各个字段之间用换行符连接,比把JSON重新序列化后再签名的方式更容易保持两端对齐不出错;正文必须直接用HTTP读到的原始字节,不能先解析JSON、重新排序字段之后再拼回去算签名。

POST
/hooks/payment
1784630400
8f4c7b2a
{"event":"paid","order_id":"A1024"}

签名值最终传输为十六进制字符串,加密用的密钥只存在服务端的配置里,绝对不能对外暴露。路径也要纳入签名基串的计算范围,不然同一份合法正文可能被攻击者拿到另一个接口冒用。

Go Webhook 签名基串从请求方法、路径、时间戳和 Nonce 汇总到 HMAC 比对的工程证据图

用Go写最小可复查的校验器

下面的函数只负责做身份和合法性校验,不直接修改订单或积分这类业务数据。把职责拆分开之后,单元测试可以分别覆盖过期请求、错误正文、错误签名和重复Nonce这类不同场景。

package webhook

import (
    "crypto/hmac"
    "crypto/sha256"
    "crypto/subtle"
    "encoding/hex"
    "fmt"
    "strconv"
    "strings"
    "time"
)

func buildBase(method, path, stamp, nonce string, body []byte) string {
    return strings.Join([]string{
        method, path, stamp, nonce, string(body),
    }, "\n")
}

func verifyMAC(secret []byte, base, supplied string) bool {
    expected := hmac.New(sha256.New, secret)
    _, _ = expected.Write([]byte(base))
    got, err := hex.DecodeString(supplied)
    if err != nil || len(got) != sha256.Size {
        return false
    }
    // 先固定长度,再做常量时间比较。
    return subtle.ConstantTimeCompare(expected.Sum(nil), got) == 1
}

func verifyFresh(stamp string, now time.Time, window time.Duration) error {
    seconds, err := strconv.ParseInt(stamp, 10, 64)
    if err != nil {
        return fmt.Errorf("bad timestamp")
    }
    age := now.Sub(time.Unix(seconds, 0))
    if age  window {
        return fmt.Errorf("timestamp outside window")
    }
    return nil
}

这里同时使用了 hmac.Equal 的等价安全思想和显式摘要长度检查;如果项目不需要把比较过程拆开,建议直接写成 hmac.Equal(expected.Sum(nil), got),代码更简洁,也不容易遗漏长度校验的处理。

校验顺序决定重放窗口的安全边界

Handler的处理顺序建议固定为:读取原始正文 → 校验时间戳 → 计算并比对签名 → 原子占用Nonce → 进入业务幂等处理逻辑。时间窗校验失败要尽早返回错误,签名校验失败绝对不能写入Nonce记录;Nonce的占用操作必须是“只允许第一次请求成功写入”的原子操作。

func acceptWebhook(stamp, nonce, supplied string, body []byte, now time.Time) error {
    if err := verifyFresh(stamp, now, 5*time.Minute); err != nil {
        return err
    }
    base := buildBase("POST", "/hooks/payment", stamp, nonce, body)
    if !verifyMAC([]byte("server-only-secret"), base, supplied) {
        return fmt.Errorf("signature mismatch")
    }
    // replayStore.PutIfAbsent 需要在存储侧原子完成,并设置 10 分钟 TTL。
    if !replayStore.PutIfAbsent(nonce, 10*time.Minute) {
        return fmt.Errorf("nonce already used")
    }
    return nil
}

Nonce的存储可以是带TTL过期的Redis键,也可以是数据库里的唯一索引。多实例部署的时候绝对不能只把Nonce放进单台进程的内存map,不然请求落到不同的实例上,第二次重放依然会被放行。

Go Webhook 先检查五分钟时间窗,再以 Nonce 一次性记录拦截重复请求的工程证据图

跑一组完整检查,确认每个边界都真的生效

测试不要只覆盖单条正常成功路径。至少要准备同一正文的首次请求、重复Nonce、过期时间戳、篡改后的正文、错误签名五组输入。特别是重复Nonce的场景,要保证第一次调用成功、第二次调用返回失败,不能出现两次都因为测试存储初始化异常直接失败的情况。

func TestReplayIsRejected(t *testing.T) {
    now := time.Unix(1784630400, 0)
    nonce := "8f4c7b2a"
    body := []byte(`{"event":"paid","order_id":"A1024"}`)
    signature := signForTest("POST", "/hooks/payment", "1784630400", nonce, body)

    if err := acceptWebhook("1784630400", nonce, signature, body, now); err != nil {
        t.Fatal(err)
    }
    if err := acceptWebhook("1784630400", nonce, signature, body, now); err == nil {
        t.Fatal("replay was accepted")
    }
}

上线前再补一组时钟偏差测试。服务器时间比发送方快4分钟应当正常放行,快6分钟应当直接拒绝;时间窗口不是越大越好,窗口拉得越长,泄露的合法请求可被攻击者利用的时间也就越久。

别把Nonce当成业务幂等键直接用

第三方重试逻辑可能会生成全新的Nonce,但携带完全相同的订单事件内容。这种场景下防重放层会把它判定为新请求直接放行,业务层还需要用 event_id 或者订单号建立对应的幂等记录。推荐保留两层判断逻辑:Nonce用来防止同一报文被重复投递,业务幂等键用来防止同一事件被不同的合法报文重复执行。

  • 密钥按调用方或环境做隔离,密钥轮换的时候支持短暂的旧密钥验证窗口,避免线上流量直接报错。
  • 日志记录请求ID、时间窗校验结果和失败原因,不要记录密钥、完整正文或者完整签名字符串。
  • 拒绝响应尽量返回统一的通用错误提示,不要向外暴露“时间过期”还是“签名错误”这类细节信息。

常见问题

只校验HMAC,不校验时间戳可以吗?

没法防止合法报文被原样重发。HMAC保证的是请求完整性和密钥持有关系,时间戳和Nonce才负责限制这份报文的可用次数和有效时间范围。

Nonce应该保存多久?

通常覆盖设定的时间窗之后再留一点余量就可以,比如时间窗设5分钟,Nonce的TTL就设成10分钟。具体数值要结合第三方的重试间隔和服务器时钟偏差测试的结果来调整。

多台Go实例都用本地map存Nonce行不行?

不行。请求可能被负载均衡分发到不同实例,本地map彼此看不到对方已经写入的Nonce记录。应该使用共享存储做原子写入,或者让网关提供等价的集中去重能力。

为什么业务层的幂等逻辑还不能省掉?

同一业务事件可以被携带不同Nonce的重试报文承载。Nonce解决的是“这份报文有没有来过”的问题,业务幂等键解决的是“这个事件有没有已经被处理过”的问题。

小结

一套可维护的Webhook认证逻辑至少要同时守住四个要点:原始正文不被篡改、签名基串规则稳定、时间戳处在设定的短窗口内、Nonce只能被成功消费一次。再把业务事件ID做好幂等处理,就能覆盖第三方重试、网络重复投递和恶意重放这几类不同的异常场景。

声明:本文转载于:17golang原创 如有侵犯,请联系study_golang@163.com删除
相关阅读
更多>
最新阅读
更多>
课程推荐
更多>