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
| @@ -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 | |||||
| } | |||||
| @@ -112,6 +112,11 @@ func SubscriptionRequestEpay(c *gin.Context) { | |||||
| } | } | ||||
| func SubscriptionEpayNotify(c *gin.Context) { | func SubscriptionEpayNotify(c *gin.Context) { | ||||
| if !isEpayWebhookEnabled() { | |||||
| _, _ = c.Writer.Write([]byte("fail")) | |||||
| return | |||||
| } | |||||
| var params map[string]string | var params map[string]string | ||||
| if c.Request.Method == "POST" { | if c.Request.Method == "POST" { | ||||
| @@ -258,7 +258,8 @@ func RequestEpay(c *gin.Context) { | |||||
| Amount: amount, | Amount: amount, | ||||
| Money: payMoney, | Money: payMoney, | ||||
| TradeNo: tradeNo, | TradeNo: tradeNo, | ||||
| PaymentMethod: req.PaymentMethod, | |||||
| PaymentMethod: req.PaymentMethod, | |||||
| PaymentProvider: model.PaymentProviderEpay, | |||||
| CreateTime: time.Now().Unix(), | CreateTime: time.Now().Unix(), | ||||
| Status: "pending", | Status: "pending", | ||||
| } | } | ||||
| @@ -298,6 +299,12 @@ func UnlockOrder(tradeNo string) { | |||||
| } | } | ||||
| func EpayNotify(c *gin.Context) { | func EpayNotify(c *gin.Context) { | ||||
| if !isEpayWebhookEnabled() { | |||||
| log.Println("易支付 webhook 被拒绝: 易支付未配置或已禁用") | |||||
| _, _ = c.Writer.Write([]byte("fail")) | |||||
| return | |||||
| } | |||||
| var params map[string]string | var params map[string]string | ||||
| if c.Request.Method == "POST" { | if c.Request.Method == "POST" { | ||||
| @@ -190,7 +190,8 @@ func RequestAlipayPay(c *gin.Context) { | |||||
| Amount: req.Amount, | Amount: req.Amount, | ||||
| Money: chargedMoney, | Money: chargedMoney, | ||||
| TradeNo: tradeNo, | TradeNo: tradeNo, | ||||
| PaymentMethod: PaymentMethodAlipay, | |||||
| PaymentMethod: PaymentMethodAlipay, | |||||
| PaymentProvider: model.PaymentProviderAlipay, | |||||
| CreateTime: time.Now().Unix(), | CreateTime: time.Now().Unix(), | ||||
| Status: common.TopUpStatusPending, | Status: common.TopUpStatusPending, | ||||
| } | } | ||||
| @@ -239,6 +240,12 @@ func AlipayPayStatus(c *gin.Context) { | |||||
| // AlipayPayWebhook 处理支付宝异步回调通知 | // AlipayPayWebhook 处理支付宝异步回调通知 | ||||
| func AlipayPayWebhook(c *gin.Context) { | 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) | notifyReq, err := alipay.ParseNotifyToBodyMap(c.Request) | ||||
| if err != nil { | if err != nil { | ||||
| log.Printf("解析支付宝回调失败: %v", err) | log.Printf("解析支付宝回调失败: %v", err) | ||||
| @@ -108,12 +108,13 @@ func (*CreemAdaptor) RequestPay(c *gin.Context, req *CreemPayRequest) { | |||||
| // 先创建订单记录,使用产品配置的金额和充值额度 | // 先创建订单记录,使用产品配置的金额和充值额度 | ||||
| topUp := &model.TopUp{ | 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() | err = topUp.Insert() | ||||
| if err != nil { | if err != nil { | ||||
| @@ -229,6 +230,12 @@ type CreemWebhookEvent struct { | |||||
| } | } | ||||
| func CreemWebhook(c *gin.Context) { | func CreemWebhook(c *gin.Context) { | ||||
| if !isCreemWebhookEnabled() { | |||||
| log.Printf("Creem webhook 被拒绝: webhook 未配置或已禁用 (client_ip=%s)\n", c.ClientIP()) | |||||
| c.AbortWithStatus(http.StatusForbidden) | |||||
| return | |||||
| } | |||||
| // 读取body内容用于打印,同时保留原始数据供后续使用 | // 读取body内容用于打印,同时保留原始数据供后续使用 | ||||
| bodyBytes, err := io.ReadAll(c.Request.Body) | bodyBytes, err := io.ReadAll(c.Request.Body) | ||||
| if err != nil { | if err != nil { | ||||
| @@ -108,7 +108,8 @@ func (*StripeAdaptor) RequestPay(c *gin.Context, req *StripePayRequest) { | |||||
| Amount: req.Amount, | Amount: req.Amount, | ||||
| Money: chargedMoney, | Money: chargedMoney, | ||||
| TradeNo: referenceId, | TradeNo: referenceId, | ||||
| PaymentMethod: PaymentMethodStripe, | |||||
| PaymentMethod: PaymentMethodStripe, | |||||
| PaymentProvider: model.PaymentProviderStripe, | |||||
| CreateTime: time.Now().Unix(), | CreateTime: time.Now().Unix(), | ||||
| Status: common.TopUpStatusPending, | Status: common.TopUpStatusPending, | ||||
| } | } | ||||
| @@ -146,6 +147,12 @@ func RequestStripePay(c *gin.Context) { | |||||
| } | } | ||||
| func StripeWebhook(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) | payload, err := io.ReadAll(c.Request.Body) | ||||
| if err != nil { | if err != nil { | ||||
| log.Printf("解析Stripe Webhook参数失败: %v\n", err) | log.Printf("解析Stripe Webhook参数失败: %v\n", err) | ||||
| @@ -246,7 +246,8 @@ func RequestWechatPay(c *gin.Context) { | |||||
| Amount: req.Amount, | Amount: req.Amount, | ||||
| Money: chargedMoney, | Money: chargedMoney, | ||||
| TradeNo: tradeNo, | TradeNo: tradeNo, | ||||
| PaymentMethod: PaymentMethodWechatPay, | |||||
| PaymentMethod: PaymentMethodWechatPay, | |||||
| PaymentProvider: model.PaymentProviderWechat, | |||||
| CreateTime: time.Now().Unix(), | CreateTime: time.Now().Unix(), | ||||
| Status: common.TopUpStatusPending, | Status: common.TopUpStatusPending, | ||||
| } | } | ||||
| @@ -296,6 +297,12 @@ func WechatPayStatus(c *gin.Context) { | |||||
| // WechatPayWebhook 处理微信支付回调通知 | // WechatPayWebhook 处理微信支付回调通知 | ||||
| func WechatPayWebhook(c *gin.Context) { | 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) | notifyReq, err := wechat.V3ParseNotify(c.Request) | ||||
| if err != nil { | if err != nil { | ||||
| log.Printf("解析微信支付回调失败: %v", err) | log.Printf("解析微信支付回调失败: %v", err) | ||||
| @@ -13,18 +13,27 @@ import ( | |||||
| ) | ) | ||||
| type TopUp struct { | 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 记录的用户邮箱 | // fillTopUpEmails 批量填充 topup 记录的用户邮箱 | ||||
| func fillTopUpEmails(topups []*TopUp) { | func fillTopUpEmails(topups []*TopUp) { | ||||
| if len(topups) == 0 { | if len(topups) == 0 { | ||||
| @@ -113,7 +122,10 @@ func Recharge(referenceId string, customerId string) (err error) { | |||||
| } | } | ||||
| quota = topUp.Money * common.QuotaPerUnit | 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 { | if err != nil { | ||||
| return err | return err | ||||
| } | } | ||||
| @@ -360,6 +372,11 @@ func RechargeCreem(referenceId string, customerEmail string, customerName string | |||||
| return errors.New("充值订单状态错误") | return errors.New("充值订单状态错误") | ||||
| } | } | ||||
| // 防止跨网关回调攻击 | |||||
| if topUp.PaymentProvider != "" && topUp.PaymentProvider != PaymentProviderCreem { | |||||
| return fmt.Errorf("支付网关不匹配: 订单由 %s 创建, 但 Creem webhook 尝试完成", topUp.PaymentProvider) | |||||
| } | |||||
| topUp.CompleteTime = common.GetTimestamp() | topUp.CompleteTime = common.GetTimestamp() | ||||
| topUp.Status = common.TopUpStatusSuccess | topUp.Status = common.TopUpStatusSuccess | ||||
| err = tx.Save(topUp).Error | err = tx.Save(topUp).Error | ||||
| @@ -59,7 +59,11 @@ func rechargeByQRCodePayment(tradeNo string, paymentMethod string) error { | |||||
| dMoney := decimal.NewFromFloat(topUp.Money) | dMoney := decimal.NewFromFloat(topUp.Money) | ||||
| dQuotaPerUnit := decimal.NewFromFloat(common.QuotaPerUnit) | 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 { | if quotaToAdd <= 0 { | ||||
| return errors.New("无效的充值额度") | return errors.New("无效的充值额度") | ||||