Buffalo框架中HMAC验签必须绕过默认JSON解析,直接读取原始字节流,否则因字段顺序、空格、换行、布尔值格式等差异导致签名不一致;需在控制器入口用io.ReadAll一次性读取Body并重置,密钥转[]byte后用hmac.New+hex.EncodeToString计算,再以hmac.Equal恒定时间比对。

HMAC签名核验在Buffalo框架里不能靠默认JSON解析中间件完成,必须绕过自动解码、直接读取原始字节流。 Buffalo 默认对 application/json 请求使用 json.Decode 解析到结构体,这会破坏原始 payload 的字节顺序、空格、换行和布尔值格式,导致 HMAC 计算结果与第三方不一致——这是验签失败最常见原因。
为什么Buffalo的默认JSON解析会破坏HMAC验签
第三方(如 GitHub、Stripe、Composio)生成签名时,用的是 HTTP 请求体的原始字节(b'{"event":"push","repository":{"name":"test"}}'),而非解析后的 map 或 struct。Buffalo 的 c.Bind() 或 c.Param() 会触发 JSON 解码,把 true 变成 1、把换行缩进抹平、把空字符串和 null 混淆,甚至在字段顺序上也不保证一致。这些微小差异足以让 hmac-sha256 输出完全不同。
- Buffalo 的
JSON中间件默认启用,且不可禁用——它绑定在路由层,无法为单个 handler 关闭 - 即使你手动调用
c.Request().Body,也必须在任何 Bind 调用前读取,否则 Body 已被消耗为空 - 不能依赖
c.Request().FormValue()或c.Request().PostForm,它们只适用于application/x-www-form-urlencoded,且同样不是原始字节
如何在Buffalo控制器中安全读取原始payload
关键是在 controller 入口处,用 io.ReadAll 一次性读取 c.Request().Body,并用 bytes.NewReader 重置 Body,供后续可能的 Bind 使用(如果业务逻辑仍需结构化解析)。
Buffalo框架 1.0.1 版本源码包下载,适合需要错误处理改进、依赖更新、render.Download 注释和 request logger 调整的 v1 项目。
func WebhookHandler(c buffalo.Context) error {
// 1. 提前读取原始字节流
body, err := io.ReadAll(c.Request().Body)
if err != nil {
return c.Error(400, err)
}
// 2. 重置 Body,避免影响后续 Bind(可选)
c.Request().Body = io.NopCloser(bytes.NewReader(body))
// 3. 提取签名头(以 X-Hub-Signature-256 为例)
sig := c.Request().Header.Get("X-Hub-Signature-256")
if sig == "" {
return c.Error(400, errors.New("missing X-Hub-Signature-256"))
}
sig = strings.TrimPrefix(sig, "sha256=")
// 4. 验证签名(见下节)
if !verifyHMAC(body, sig, os.Getenv("WEBHOOK_SECRET")) {
return c.Error(403, errors.New("invalid signature"))
}
// 5. 此时可安全解析 JSON(已确保 payload 未被篡改)
var payload map[string]interface{}
if err := json.Unmarshal(body, &payload); err != nil {
return c.Error(400, err)
}
// 处理业务逻辑...
return c.Render(200, r.JSON("ok"))
}
verifyHMAC函数必须满足三个硬性条件
Buffalo 不提供内置 HMAC 验证工具,你需要自己实现,且必须满足以下三点,否则仍会失败:
- 使用
hmac.New+sha256.New,写入body后调用sum.Sum(nil),再用hex.EncodeToString转为字符串,与 header 中的签名比对 - 密钥必须是
[]byte类型,且不能额外 trim 空格或做 base64 decode —— 第三方给你的 secret 就是原始字符串,直接[]byte(secret) - 比对必须用恒定时间函数:Go 标准库的
hmac.Equal,严禁用==或strings.EqualFold
示例片段:
func verifyHMAC(payload []byte, sig string, secret string) bool {
mac := hmac.New(sha256.New, []byte(secret))
mac.Write(payload)
expected := hex.EncodeToString(mac.Sum(nil))
sigBytes, _ := hex.DecodeString(sig)
expectedBytes := []byte(expected)
return hmac.Equal(sigBytes, expectedBytes)
}
最容易被忽略的是:Buffalo 的 c.Error 默认返回 HTML 页面,而 Webhook 发送方(尤其是 Stripe、GitHub)期望纯文本或空响应。务必在返回错误时显式设置 Content-Type: text/plain,否则部分平台会因解析失败而反复重试。










