From fcb1c2a8bae23ad0dfbf36a4189f717478add48c Mon Sep 17 00:00:00 2001 From: fengsilin Date: Thu, 16 Jul 2026 15:56:15 +0800 Subject: [PATCH] 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 --- controller/payment_webhook_availability.go | 55 ++++++++++++++++++++++ controller/subscription_payment_epay.go | 5 ++ controller/topup.go | 9 +++- controller/topup_alipay.go | 9 +++- controller/topup_creem.go | 19 +++++--- controller/topup_stripe.go | 9 +++- controller/topup_wechat.go | 9 +++- model/topup.go | 39 ++++++++++----- model/topup_wechat.go | 6 ++- 9 files changed, 138 insertions(+), 22 deletions(-) create mode 100644 controller/payment_webhook_availability.go diff --git a/controller/payment_webhook_availability.go b/controller/payment_webhook_availability.go new file mode 100644 index 0000000..29d7e22 --- /dev/null +++ b/controller/payment_webhook_availability.go @@ -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 +} diff --git a/controller/subscription_payment_epay.go b/controller/subscription_payment_epay.go index 528ab5d..f6fe3df 100644 --- a/controller/subscription_payment_epay.go +++ b/controller/subscription_payment_epay.go @@ -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" { diff --git a/controller/topup.go b/controller/topup.go index 156f05e..8c2fab3 100644 --- a/controller/topup.go +++ b/controller/topup.go @@ -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" { diff --git a/controller/topup_alipay.go b/controller/topup_alipay.go index b16822b..d0bf853 100644 --- a/controller/topup_alipay.go +++ b/controller/topup_alipay.go @@ -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) diff --git a/controller/topup_creem.go b/controller/topup_creem.go index 54b67b8..81bb8f5 100644 --- a/controller/topup_creem.go +++ b/controller/topup_creem.go @@ -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 { diff --git a/controller/topup_stripe.go b/controller/topup_stripe.go index e1718cc..d44e0c2 100644 --- a/controller/topup_stripe.go +++ b/controller/topup_stripe.go @@ -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) diff --git a/controller/topup_wechat.go b/controller/topup_wechat.go index 9eae550..0eb91e7 100644 --- a/controller/topup_wechat.go +++ b/controller/topup_wechat.go @@ -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) diff --git a/model/topup.go b/model/topup.go index 1f789af..19a9ac8 100644 --- a/model/topup.go +++ b/model/topup.go @@ -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 diff --git a/model/topup_wechat.go b/model/topup_wechat.go index bc799d3..39093ef 100644 --- a/model/topup_wechat.go +++ b/model/topup_wechat.go @@ -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("无效的充值额度")