时间戳必须是毫秒级整数且校验未来时间:服务端用time.now().unixmilli()与x-timestamp解析值比对,允许误差±10000ms,超5分钟窗口或未来时间超限即拒绝。

时间戳必须是毫秒级整数且校验未来时间
服务端拿到 X-Timestamp 后不能直接用 time.Now().Unix() 做减法——它返回秒级,而客户端大概率传的是毫秒(比如 Date.now())。单位不一致会导致永远校验失败。
正确做法是统一用毫秒:服务端用 time.Now().UnixMilli(),再与请求头中解析出的整数值做差。同时必须检查 timestamp 是否为未来时间,允许误差最多 10 秒(即 10000 毫秒),否则攻击者可伪造远期时间绕过校验。
- 用
c.Request.Header.Get("X-Timestamp")提取,别从 query 或 body 读(POST JSON 场景下 query 为空) - 用
strconv.ParseInt转换,失败就c.AbortWithStatus(400) - 校验逻辑:
if now-ts > 300000 || ts > now+10000→ 拒绝(300000ms = 5 分钟窗口) - 没做未来时间检查是高频漏点,本地开发时服务器时间略快于客户端就会误杀
为什么只校验时间戳根本防不住重放
单纯判断 X-Timestamp 在 5 分钟内,等于给攻击者开了一个“合法重放窗口”。只要他在窗口期内把抓到的完整请求(含签名、nonce、body)原样重发一次,所有校验都通过。
真正要拦住的是“同一份签名被重复消费”,而不是“旧请求延迟到达”。时间戳只是时效开关,不是唯一性凭证。没有 nonce + Redis 原子去重,这个中间件等同于摆设。
- 常见错误:中间件里只写了
if time.Since(ts) > 5*time.Minute就放行 - 更隐蔽的错:把 nonce 存在本地 map,多实例部署下完全失效
- 降级逻辑必须明确——Redis 不可用时,至少保留 timestamp 校验,不能直接跳过
时间戳必须参与签名原文拼接,且顺序固定
签名不是为了“证明时间有效”,而是把时间戳和请求内容强绑定。如果 timestamp 没进签名原文,攻击者就能在有效期内随意修改 body 并重放。
拼接顺序必须和服务端完全一致,否则 HMAC 对不上。推荐格式:method + "\n" + path + "\n" + timestamp + "\n" + nonce + "\n" + body,用换行符分隔防粘连。
- body 必须是原始字节流,不能是格式化后的 JSON 字符串(空格、缩进、字段顺序都会影响哈希)
- 若用 jsoniter,启用
SortMapKeys(true)确保字段顺序稳定 - timestamp 字段在签名原文里必须和 header 中传的一致——不能 header 传毫秒,签名里拼秒级
- 千万别在签名前对 body 做任何修改(如 gzip 解压、urldecode),那和客户端不一致
中间件里读取 Request.Body 的坑必须填平
Gin 的 c.Request.Body 是单次读取流。你用 ioutil.ReadAll 算完签名后,后续 c.ShouldBindJSON 会读到空 body,导致参数解析失败。
必须手动重置 Body:读完后用 c.Request.Body = io.NopCloser(bytes.NewBuffer(bodyBytes)) 包一层,才能让下游 handler 正常解析。
- 顺序不能错:先读 body → 计算签名 → 重置 Body → 再做 timestamp/nonce 校验 → 最后 c.Next()
- panic 处理要兜底:Body 读取出错(如超大 payload)应记录日志并返回 400,不能让 panic 泄露到外层
- GET 请求不用读 body,但中间件仍要判断 method 类型,避免对 GET 执行无意义的 body 读取
nil 时没当重放处理。











