Files
Logan.Li 1c50d565eb
CI / backend (push) Canceled after 0s
fix(security): webhook fail-closed、机密字段信封加密、限流信任代理修正
三项 P0 安全修复:

1. Webhook 验签 fail-closed
   - WaffoWebhook 未配置验签材料时从「告警放行」改为 503 拒绝,
     封死伪造 order.completed 结算订单的路径
   - 通用 PaymentWebhook 从「仅 release 拒绝」收紧为默认拒绝,
     仅显式 ALLOW_UNVERIFIED_WEBHOOK=1(本地调试)放行

2. 机密字段信封加密(新增 internal/secretbox)
   - AES-256-GCM,主密钥 OPSLIC_MASTER_KEY(64 位 hex),密文带
     enc:v1: 前缀;Encrypt 幂等、历史明文原样放行
   - PaymentGatewayConfig.SecretKey/WebhookSecret 与 PeerNode.
     SharedKey 挂 GORM 钩子透明加解密,业务代码零改动;SharedKey
     列宽 128→512(密文更长)
   - 启动 fail-fast 校验主密钥;MigratePlaintext 幂等迁移存量明文;
     节点同步只含业务表,机密表为节点本地,互不影响
   - docker-compose/.env.example 补 OPSLIC_MASTER_KEY 与说明

3. 限流信任代理修正
   - 默认 SetTrustedProxies(nil) 不信任任何 X-Forwarded-For,
     封死伪造 XFF 轮换 IP 绕过按 IP 限流;反代节点用
     TRUSTED_PROXIES 指定内网 CIDR,非法配置拒绝启动

验证:go build/vet/test 全绿(含 secretbox 6 条单测);
读取路径(Waffo 私钥/webhook 密钥/互联密钥)确认全走 GORM 钩子。

部署注意:上线须配置 OPSLIC_MASTER_KEY(双节点一致)与
WAFFO_WEBHOOK_PUBLIC_KEY(未配则回调 503,属预期);香港反代
节点配 TRUSTED_PROXIES。
2026-11-01 02:03:26 +08:00

105 lines
3.1 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package secretbox 为落库机密字段提供信封加密(AES-256-GCM)。
// 主密钥来自环境变量 OPSLIC_MASTER_KEY(64 位 hex = 32 字节),启动时装载,
// 缺失或非法即 fail-fast。密文带 enc:v1: 前缀;无前缀的值视为历史明文,
// Decrypt 原样返回——支持存量数据一次性迁移渐进完成。 // Logan.Li
package secretbox
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"encoding/base64"
"encoding/hex"
"errors"
"fmt"
"os"
"strings"
"sync"
)
// Prefix 密文前缀;同时用于迁移时识别未加密的历史明文。 // Logan.Li
const Prefix = "enc:v1:"
var (
mu sync.RWMutex
aed cipher.AEAD
)
// LoadMasterKey 从 OPSLIC_MASTER_KEY 装载主密钥;缺失/非法返回错误(调用方应 fail-fast)。 // Logan.Li
func LoadMasterKey() error {
raw := strings.TrimSpace(os.Getenv("OPSLIC_MASTER_KEY"))
if raw == "" {
return errors.New("未配置 OPSLIC_MASTER_KEY(64 位 hex,32 字节):机密字段加密不可用,拒绝启动")
}
key, err := hex.DecodeString(raw)
if err != nil || len(key) != 32 {
return errors.New("OPSLIC_MASTER_KEY 必须是 64 位 hex(32 字节)")
}
block, err := aes.NewCipher(key)
if err != nil {
return fmt.Errorf("初始化 AES 失败: %w", err)
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return fmt.Errorf("初始化 GCM 失败: %w", err)
}
mu.Lock()
aed = gcm
mu.Unlock()
return nil
}
// Ready 报告主密钥是否已装载。 // Logan.Li
func Ready() bool {
mu.RLock()
defer mu.RUnlock()
return aed != nil
}
// Encrypt 加密明文:空串与已是密文的值原样返回(幂等,可安全重复调用)。 // Logan.Li
func Encrypt(plain string) (string, error) {
if plain == "" || strings.HasPrefix(plain, Prefix) {
return plain, nil
}
mu.RLock()
g := aed
mu.RUnlock()
if g == nil {
return "", errors.New("secretbox: 主密钥未装载")
}
nonce := make([]byte, g.NonceSize())
if _, err := rand.Read(nonce); err != nil {
return "", fmt.Errorf("生成 nonce 失败: %w", err)
}
sealed := g.Seal(nonce, nonce, []byte(plain), nil)
return Prefix + base64.StdEncoding.EncodeToString(sealed), nil
}
// Decrypt 解密:无前缀(空值或历史明文)原样返回;密文被篡改时返回错误。 // Logan.Li
func Decrypt(s string) (string, error) {
if !strings.HasPrefix(s, Prefix) {
return s, nil
}
mu.RLock()
g := aed
mu.RUnlock()
if g == nil {
return "", errors.New("secretbox: 主密钥未装载,无法解密机密字段")
}
raw, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(s, Prefix))
if err != nil {
return "", fmt.Errorf("机密字段 base64 解码失败: %w", err)
}
if len(raw) < g.NonceSize() {
return "", errors.New("机密字段密文长度非法")
}
plain, err := g.Open(nil, raw[:g.NonceSize()], raw[g.NonceSize():], nil)
if err != nil {
return "", fmt.Errorf("机密字段解密失败(主密钥不一致或密文被篡改): %w", err)
}
return string(plain), nil
}
// MustDecrypt 供钩子使用:解密失败直接返回错误,让读写显式失败而非静默拿错值。 // Logan.Li
func MustDecrypt(s string) (string, error) { return Decrypt(s) }