Ver código fonte

fix: prevent webhook bypass via empty secret and cross-gateway callback attacks

Add two layers of defense:

1. Webhook availability guard (controller/payment_webhook_availability.go):
   - isStripeWebhookEnabled() checks StripeWebhookSecret != "" before processing
   - isCreemWebhookEnabled(), isWechatPayWebhookEnabled(), isAlipayWebhookEnabled()
   - isEpayWebhookEnabled() with similar checks for all payment webhooks
   - Applied to: StripeWebhook, CreemWebhook, WechatPayWebhook, AlipayPayWebhook,
     EpayNotify, SubscriptionEpayNotify

2. PaymentProvider field (model/topup.go):
   - New PaymentProvider field on TopUp to identify which gateway created the order
   - Recharge() checks PaymentProvider == PaymentProviderStripe
   - rechargeByQRCodePayment() checks PaymentProvider matches wechat/alipay
   - RechargeCreem() checks PaymentProvider == PaymentProviderCreem
   - All payment controllers set PaymentProvider when creating orders

Root cause: When StripeWebhookSecret was empty, ComputeSignature used
an empty HMAC key, allowing attackers to forge valid signatures and
complete orders from any payment gateway without actually paying.

Co-Authored-By: Claude <noreply@anthropic.com>
master
fengsilin 1 mês atrás
pai
commit
fcb1c2a8ba
9 arquivos alterados com 138 adições e 22 exclusões
  1. +55
    -0
      controller/payment_webhook_availability.go
  2. +5
    -0
      controller/subscription_payment_epay.go
  3. +8
    -1
      controller/topup.go
  4. +8
    -1
      controller/topup_alipay.go
  5. +13
    -6
      controller/topup_creem.go
  6. +8
    -1
      controller/topup_stripe.go
  7. +8
    -1
      controller/topup_wechat.go
  8. +28
    -11
      model/topup.go
  9. +5
    -1
      model/topup_wechat.go

+ 55
- 0
controller/payment_webhook_availability.go Ver arquivo

@@ -0,0 +1,55 @@
package controller

import (
"strings"

"github.com/QuantumNous/new-api/setting"
"github.com/QuantumNous/new-api/setting/operation_setting"
)

// isStripeTopUpEnabled 检查 Stripe 支付是否已启用(三项配置缺一不可)
func isStripeTopUpEnabled() bool {
return strings.TrimSpace(setting.StripeApiSecret) != "" &&
strings.TrimSpace(setting.StripeWebhookSecret) != "" &&
strings.TrimSpace(setting.StripePriceId) != ""
}

// isStripeWebhookEnabled 检查 Stripe Webhook 是否可接收
func isStripeWebhookEnabled() bool {
return isStripeTopUpEnabled()
}

// isCreemTopUpEnabled 检查 Creem 支付是否已启用
func isCreemTopUpEnabled() bool {
products := strings.TrimSpace(setting.CreemProducts)
return strings.TrimSpace(setting.CreemApiKey) != "" &&
products != "" &&
products != "[]"
}

// isCreemWebhookEnabled 检查 Creem Webhook 是否可接收
func isCreemWebhookEnabled() bool {
return isCreemTopUpEnabled() && strings.TrimSpace(setting.CreemWebhookSecret) != ""
}

// isWechatPayWebhookEnabled 检查微信支付 Webhook 是否可接收
func isWechatPayWebhookEnabled() bool {
return setting.IsWechatPayConfigured()
}

// isAlipayWebhookEnabled 检查支付宝 Webhook 是否可接收
func isAlipayWebhookEnabled() bool {
return setting.IsAlipayConfigured()
}

// isEpayTopUpEnabled 检查易支付是否已启用
func isEpayTopUpEnabled() bool {
return strings.TrimSpace(operation_setting.PayAddress) != "" &&
strings.TrimSpace(operation_setting.EpayId) != "" &&
strings.TrimSpace(operation_setting.EpayKey) != ""
}

// isEpayWebhookEnabled 检查易支付 Webhook 是否可接收
func isEpayWebhookEnabled() bool {
return isEpayTopUpEnabled() && len(operation_setting.PayMethods) > 0
}

+ 5
- 0
controller/subscription_payment_epay.go Ver arquivo

@@ -112,6 +112,11 @@ func SubscriptionRequestEpay(c *gin.Context) {
}

func SubscriptionEpayNotify(c *gin.Context) {
if !isEpayWebhookEnabled() {
_, _ = c.Writer.Write([]byte("fail"))
return
}

var params map[string]string

if c.Request.Method == "POST" {


+ 8
- 1
controller/topup.go Ver arquivo

@@ -258,7 +258,8 @@ func RequestEpay(c *gin.Context) {
Amount: amount,
Money: payMoney,
TradeNo: tradeNo,
PaymentMethod: req.PaymentMethod,
PaymentMethod: req.PaymentMethod,
PaymentProvider: model.PaymentProviderEpay,
CreateTime: time.Now().Unix(),
Status: "pending",
}
@@ -298,6 +299,12 @@ func UnlockOrder(tradeNo string) {
}

func EpayNotify(c *gin.Context) {
if !isEpayWebhookEnabled() {
log.Println("易支付 webhook 被拒绝: 易支付未配置或已禁用")
_, _ = c.Writer.Write([]byte("fail"))
return
}

var params map[string]string

if c.Request.Method == "POST" {


+ 8
- 1
controller/topup_alipay.go Ver arquivo

@@ -190,7 +190,8 @@ func RequestAlipayPay(c *gin.Context) {
Amount: req.Amount,
Money: chargedMoney,
TradeNo: tradeNo,
PaymentMethod: PaymentMethodAlipay,
PaymentMethod: PaymentMethodAlipay,
PaymentProvider: model.PaymentProviderAlipay,
CreateTime: time.Now().Unix(),
Status: common.TopUpStatusPending,
}
@@ -239,6 +240,12 @@ func AlipayPayStatus(c *gin.Context) {

// AlipayPayWebhook 处理支付宝异步回调通知
func AlipayPayWebhook(c *gin.Context) {
if !isAlipayWebhookEnabled() {
log.Printf("支付宝 webhook 被拒绝: 支付宝未配置 (client_ip=%s)\n", c.ClientIP())
c.String(http.StatusForbidden, "fail")
return
}

notifyReq, err := alipay.ParseNotifyToBodyMap(c.Request)
if err != nil {
log.Printf("解析支付宝回调失败: %v", err)


+ 13
- 6
controller/topup_creem.go Ver arquivo

@@ -108,12 +108,13 @@ func (*CreemAdaptor) RequestPay(c *gin.Context, req *CreemPayRequest) {

// 先创建订单记录,使用产品配置的金额和充值额度
topUp := &model.TopUp{
UserId: id,
Amount: selectedProduct.Quota, // 充值额度
Money: selectedProduct.Price, // 支付金额
TradeNo: referenceId,
CreateTime: time.Now().Unix(),
Status: common.TopUpStatusPending,
UserId: id,
Amount: selectedProduct.Quota, // 充值额度
Money: selectedProduct.Price, // 支付金额
TradeNo: referenceId,
PaymentProvider: model.PaymentProviderCreem,
CreateTime: time.Now().Unix(),
Status: common.TopUpStatusPending,
}
err = topUp.Insert()
if err != nil {
@@ -229,6 +230,12 @@ type CreemWebhookEvent struct {
}

func CreemWebhook(c *gin.Context) {
if !isCreemWebhookEnabled() {
log.Printf("Creem webhook 被拒绝: webhook 未配置或已禁用 (client_ip=%s)\n", c.ClientIP())
c.AbortWithStatus(http.StatusForbidden)
return
}

// 读取body内容用于打印,同时保留原始数据供后续使用
bodyBytes, err := io.ReadAll(c.Request.Body)
if err != nil {


+ 8
- 1
controller/topup_stripe.go Ver arquivo

@@ -108,7 +108,8 @@ func (*StripeAdaptor) RequestPay(c *gin.Context, req *StripePayRequest) {
Amount: req.Amount,
Money: chargedMoney,
TradeNo: referenceId,
PaymentMethod: PaymentMethodStripe,
PaymentMethod: PaymentMethodStripe,
PaymentProvider: model.PaymentProviderStripe,
CreateTime: time.Now().Unix(),
Status: common.TopUpStatusPending,
}
@@ -146,6 +147,12 @@ func RequestStripePay(c *gin.Context) {
}

func StripeWebhook(c *gin.Context) {
if !isStripeWebhookEnabled() {
log.Printf("Stripe webhook 被拒绝: webhook 未配置或已禁用 (client_ip=%s)\n", c.ClientIP())
c.AbortWithStatus(http.StatusForbidden)
return
}

payload, err := io.ReadAll(c.Request.Body)
if err != nil {
log.Printf("解析Stripe Webhook参数失败: %v\n", err)


+ 8
- 1
controller/topup_wechat.go Ver arquivo

@@ -246,7 +246,8 @@ func RequestWechatPay(c *gin.Context) {
Amount: req.Amount,
Money: chargedMoney,
TradeNo: tradeNo,
PaymentMethod: PaymentMethodWechatPay,
PaymentMethod: PaymentMethodWechatPay,
PaymentProvider: model.PaymentProviderWechat,
CreateTime: time.Now().Unix(),
Status: common.TopUpStatusPending,
}
@@ -296,6 +297,12 @@ func WechatPayStatus(c *gin.Context) {

// WechatPayWebhook 处理微信支付回调通知
func WechatPayWebhook(c *gin.Context) {
if !isWechatPayWebhookEnabled() {
log.Printf("微信支付 webhook 被拒绝: 微信支付未配置 (client_ip=%s)\n", c.ClientIP())
c.JSON(http.StatusForbidden, gin.H{"code": "FAIL", "message": "微信支付未配置"})
return
}

notifyReq, err := wechat.V3ParseNotify(c.Request)
if err != nil {
log.Printf("解析微信支付回调失败: %v", err)


+ 28
- 11
model/topup.go Ver arquivo

@@ -13,18 +13,27 @@ import (
)

type TopUp struct {
Id int `json:"id"`
UserId int `json:"user_id" gorm:"index"`
Amount int64 `json:"amount"`
Money float64 `json:"money"`
TradeNo string `json:"trade_no" gorm:"unique;type:varchar(255);index"`
PaymentMethod string `json:"payment_method" gorm:"type:varchar(50)"`
CreateTime int64 `json:"create_time"`
CompleteTime int64 `json:"complete_time"`
Status string `json:"status"`
UserEmail string `json:"user_email" gorm:"-"` // Join 查询时填充,非数据库字段
Id int `json:"id"`
UserId int `json:"user_id" gorm:"index"`
Amount int64 `json:"amount"`
Money float64 `json:"money"`
TradeNo string `json:"trade_no" gorm:"unique;type:varchar(255);index"`
PaymentMethod string `json:"payment_method" gorm:"type:varchar(50)"`
PaymentProvider string `json:"payment_provider" gorm:"type:varchar(50);default:''"`
CreateTime int64 `json:"create_time"`
CompleteTime int64 `json:"complete_time"`
Status string `json:"status"`
UserEmail string `json:"user_email" gorm:"-"` // Join 查询时填充,非数据库字段
}

const (
PaymentProviderEpay = "epay"
PaymentProviderStripe = "stripe"
PaymentProviderCreem = "creem"
PaymentProviderWechat = "wechat_pay"
PaymentProviderAlipay = "alipay"
)

// fillTopUpEmails 批量填充 topup 记录的用户邮箱
func fillTopUpEmails(topups []*TopUp) {
if len(topups) == 0 {
@@ -113,7 +122,10 @@ func Recharge(referenceId string, customerId string) (err error) {
}

quota = topUp.Money * common.QuotaPerUnit
err = tx.Model(&User{}).Where("id = ?", topUp.UserId).Updates(map[string]interface{}{"stripe_customer": customerId, "quota": gorm.Expr("quota + ?", quota)}).Error
if topUp.PaymentProvider != "" && topUp.PaymentProvider != PaymentProviderStripe {
return fmt.Errorf("支付网关不匹配: 订单由 %s 创建, 但 Stripe webhook 尝试完成", topUp.PaymentProvider)
}
err = tx.Model(&User{}).Where("id = ?", topUp.UserId).Updates(map[string]interface{}{"stripe_customer": customerId, "quota": gorm.Expr("quota + ?", quota)}).Error
if err != nil {
return err
}
@@ -360,6 +372,11 @@ func RechargeCreem(referenceId string, customerEmail string, customerName string
return errors.New("充值订单状态错误")
}

// 防止跨网关回调攻击
if topUp.PaymentProvider != "" && topUp.PaymentProvider != PaymentProviderCreem {
return fmt.Errorf("支付网关不匹配: 订单由 %s 创建, 但 Creem webhook 尝试完成", topUp.PaymentProvider)
}

topUp.CompleteTime = common.GetTimestamp()
topUp.Status = common.TopUpStatusSuccess
err = tx.Save(topUp).Error


+ 5
- 1
model/topup_wechat.go Ver arquivo

@@ -59,7 +59,11 @@ func rechargeByQRCodePayment(tradeNo string, paymentMethod string) error {

dMoney := decimal.NewFromFloat(topUp.Money)
dQuotaPerUnit := decimal.NewFromFloat(common.QuotaPerUnit)
quotaToAdd = dMoney.Mul(dQuotaPerUnit).IntPart()
// 防止跨网关回调攻击:扫码支付 webhook 只能完成对应渠道创建的订单
if topUp.PaymentProvider != "" && topUp.PaymentProvider != PaymentProviderWechat && topUp.PaymentProvider != PaymentProviderAlipay {
return fmt.Errorf("支付网关不匹配: 订单由 %s 创建, 但 %s webhook 尝试完成", topUp.PaymentProvider, paymentMethod)
}
quotaToAdd = dMoney.Mul(dQuotaPerUnit).IntPart()

if quotaToAdd <= 0 {
return errors.New("无效的充值额度")


Carregando…
Cancelar
Salvar