fix: 按评审报告止血支付漏洞并加固授权链路
废除浏览器回跳自助结算,通用 webhook 强制密钥鉴权,Waffo 支持验签与未知事件忽略;重构订单状态机/激活占座/SQLite WAL,并补备份、readyz、限流与前端导出/登录页安全修复。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
8cc2c83f9d
commit
bac72e6945
24 files changed
+682
-391
No files matched your search
@@ -4,12 +4,15 @@ import (
|
||||
"backend/internal/dto"
|
||||
"backend/internal/model"
|
||||
orderservice "backend/internal/service/order"
|
||||
"backend/internal/service/payment"
|
||||
"backend/utils"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
@@ -102,8 +105,32 @@ func (ctrl *OrderController) ClaimPurchase(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"code": 0, "data": resp})
|
||||
}
|
||||
|
||||
// PaymentWebhook 支付回调(公开接口,供支付渠道调用) // Logan.Li
|
||||
// PaymentWebhook 通用支付回调:必须携带与网关配置一致的共享密钥,禁止匿名结算 // Logan.Li
|
||||
func (ctrl *OrderController) PaymentWebhook(c *gin.Context) {
|
||||
secret := strings.TrimSpace(os.Getenv("INTERNAL_WEBHOOK_SECRET"))
|
||||
if secret == "" {
|
||||
var gw model.PaymentGatewayConfig
|
||||
if err := ctrl.db.Where("enabled = ?", true).Order("priority ASC").First(&gw).Error; err == nil {
|
||||
secret = strings.TrimSpace(gw.WebhookSecret)
|
||||
}
|
||||
}
|
||||
provided := strings.TrimSpace(firstNonEmpty(
|
||||
c.GetHeader("X-Webhook-Secret"),
|
||||
c.GetHeader("X-Internal-Webhook-Secret"),
|
||||
strings.TrimPrefix(c.GetHeader("Authorization"), "Bearer "),
|
||||
))
|
||||
release := strings.EqualFold(os.Getenv("GIN_MODE"), "release")
|
||||
if secret == "" {
|
||||
if release {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"code": 401, "message": "未配置 webhook 密钥,已拒绝"})
|
||||
return
|
||||
}
|
||||
log.Println("[安全警告] INTERNAL_WEBHOOK_SECRET / WebhookSecret 未配置,开发模式放行通用回调")
|
||||
} else if provided == "" || provided != secret {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"code": 401, "message": "invalid webhook secret"})
|
||||
return
|
||||
}
|
||||
|
||||
var req dto.PaymentWebhookRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "Invalid payload"})
|
||||
@@ -141,24 +168,12 @@ func (ctrl *OrderController) WaffoWebhookReady(c *gin.Context) {
|
||||
)))
|
||||
}
|
||||
|
||||
// PurchaseSuccess browser success / claim page after Waffo checkout. // Logan.Li
|
||||
// PurchaseSuccess 浏览器回跳仅展示 DB 已确认的支付结果,绝不信任 query 自助结算 // Logan.Li
|
||||
func (ctrl *OrderController) PurchaseSuccess(c *gin.Context) {
|
||||
orderNo := firstNonEmpty(c.Query("order_no"), c.Query("orderNo"))
|
||||
email := firstNonEmpty(c.Query("email"), c.Query("customer_email"), c.Query("customerEmail"))
|
||||
planType := firstNonEmpty(c.Query("planType"), c.Query("plan_type"))
|
||||
|
||||
// If Waffo bounced here with paid-like query, try to settle once. // Logan.Li
|
||||
statusHint := strings.ToLower(firstNonEmpty(c.Query("status"), c.Query("payment_status"), c.Query("paymentStatus")))
|
||||
if orderNo != "" && (strings.Contains(statusHint, "paid") || strings.Contains(statusHint, "success") || strings.Contains(statusHint, "complete")) {
|
||||
_ = ctrl.svc.PaymentWebhook(dto.PaymentWebhookRequest{
|
||||
EventType: "browser_return",
|
||||
OrderNo: orderNo,
|
||||
Status: "paid",
|
||||
Gateway: "waffo",
|
||||
Payload: c.Request.URL.RawQuery,
|
||||
}, utils.GetClientIP(c))
|
||||
}
|
||||
|
||||
title := "支付结果"
|
||||
headline := "正在确认支付…"
|
||||
body := "若你已完成付款,请稍等数秒后刷新本页;或打开 OpsXShell,在「账户」粘贴许可证激活。"
|
||||
@@ -172,13 +187,8 @@ func (ctrl *OrderController) PurchaseSuccess(c *gin.Context) {
|
||||
switch order.Status {
|
||||
case "paid":
|
||||
headline = "支付成功"
|
||||
if order.LicenseID > 0 {
|
||||
var lic model.License
|
||||
if err := ctrl.db.First(&lic, order.LicenseID).Error; err == nil {
|
||||
licenseKey = lic.LicenseKey
|
||||
}
|
||||
}
|
||||
if licenseKey == "" {
|
||||
// 仅当邮箱匹配订单客户时展示 License,防止仅凭订单号泄露密钥 // Logan.Li
|
||||
if email != "" {
|
||||
claimed, err := ctrl.svc.ClaimPurchase(dto.ClaimPurchaseRequest{
|
||||
OrderNo: orderNo,
|
||||
CustomerEmail: email,
|
||||
@@ -187,10 +197,14 @@ func (ctrl *OrderController) PurchaseSuccess(c *gin.Context) {
|
||||
if err == nil && claimed != nil {
|
||||
licenseKey = claimed.LicenseKey
|
||||
}
|
||||
} else if order.LicenseID > 0 {
|
||||
body = "订单已支付。请在 OpsXShell 内用下单邮箱领取许可证,或刷新本页并带上 email 参数。"
|
||||
}
|
||||
if licenseKey != "" {
|
||||
body = "请复制下方许可证,回到 OpsXShell → 设置 / 升级页 → 粘贴激活。也可点击「打开应用」自动回跳。"
|
||||
} else {
|
||||
} else if order.LicenseID > 0 && email == "" {
|
||||
body = "订单已支付。为保护密钥,请使用下单邮箱在客户端领取,或访问本页并附带 email 参数。"
|
||||
} else if licenseKey == "" {
|
||||
body = "订单已支付,许可证生成中,请稍后刷新本页。"
|
||||
}
|
||||
case "pending":
|
||||
@@ -205,17 +219,8 @@ func (ctrl *OrderController) PurchaseSuccess(c *gin.Context) {
|
||||
body = "请确认是否从 OpsXShell 内发起购买。订单号:" + html.EscapeString(orderNo)
|
||||
}
|
||||
} else if email != "" {
|
||||
claimed, err := ctrl.svc.ClaimPurchase(dto.ClaimPurchaseRequest{
|
||||
CustomerEmail: email,
|
||||
ProductCode: "opsxshell",
|
||||
})
|
||||
if err == nil && claimed != nil && claimed.LicenseKey != "" {
|
||||
headline = "支付成功"
|
||||
licenseKey = claimed.LicenseKey
|
||||
orderNo = claimed.OrderNo
|
||||
orderStatus = claimed.Status
|
||||
body = "已根据邮箱找到可用许可证,请复制后在 OpsXShell 中激活。"
|
||||
}
|
||||
headline = "请提供订单号"
|
||||
body = "为保护授权安全,领取许可证需同时提供订单号与下单邮箱。"
|
||||
}
|
||||
|
||||
deepLink := "opsxshell://pro/activated"
|
||||
@@ -318,8 +323,18 @@ func (ctrl *OrderController) WaffoWebhook(c *gin.Context) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "无法读取请求体"})
|
||||
return
|
||||
}
|
||||
sigHeader := c.GetHeader("X-Waffo-Signature")
|
||||
_ = sigHeader // optional RSA verify when WAFFO_WEBHOOK_PUBLIC_KEY is set later // Logan.Li
|
||||
sigHeader := firstNonEmpty(
|
||||
c.GetHeader("X-Waffo-Signature"),
|
||||
c.GetHeader("X-Signature"),
|
||||
c.GetHeader("X-Webhook-Secret"),
|
||||
)
|
||||
verifyKey := payment.LoadWaffoWebhookVerifyKey(ctrl.db)
|
||||
if verifyKey == "" {
|
||||
log.Println("[安全警告] 未配置 WAFFO_WEBHOOK_PUBLIC_KEY/SECRET,Waffo 回调暂未强制验签;请尽快配置")
|
||||
} else if err := payment.VerifyWaffoWebhookSignature(verifyKey, body, sigHeader); err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"code": 401, "message": "invalid signature"})
|
||||
return
|
||||
}
|
||||
|
||||
var payload map[string]interface{}
|
||||
if len(body) > 0 {
|
||||
@@ -377,7 +392,8 @@ func (ctrl *OrderController) WaffoWebhook(c *gin.Context) {
|
||||
asString(payload["paymentStatus"]),
|
||||
))
|
||||
|
||||
mappedStatus := "paid"
|
||||
// 未知事件默认忽略,禁止把任意回调当成 paid // Logan.Li
|
||||
mappedStatus := ""
|
||||
switch {
|
||||
case strings.Contains(strings.ToLower(eventType), "refund"), strings.Contains(statusRaw, "refund"):
|
||||
mappedStatus = "refunded"
|
||||
@@ -386,11 +402,15 @@ func (ctrl *OrderController) WaffoWebhook(c *gin.Context) {
|
||||
mappedStatus = "failed"
|
||||
case strings.Contains(strings.ToLower(eventType), "order.completed"),
|
||||
strings.Contains(strings.ToLower(eventType), "payment.succeeded"),
|
||||
statusRaw == "" || strings.Contains(statusRaw, "paid") || strings.Contains(statusRaw, "complete") ||
|
||||
strings.Contains(statusRaw, "paid") || strings.Contains(statusRaw, "complete") ||
|
||||
strings.Contains(statusRaw, "success") || strings.Contains(statusRaw, "succeed"):
|
||||
mappedStatus = "paid"
|
||||
default:
|
||||
case statusRaw == "" && (strings.Contains(strings.ToLower(eventType), "completed") ||
|
||||
strings.Contains(strings.ToLower(eventType), "succeeded")):
|
||||
mappedStatus = "paid"
|
||||
default:
|
||||
c.JSON(http.StatusOK, gin.H{"code": 0, "message": "ignored unrecognized event"})
|
||||
return
|
||||
}
|
||||
|
||||
// Ignore non-terminal noise if event is clearly not a completion (e.g. order.created). // Logan.Li
|
||||
@@ -401,16 +421,26 @@ func (ctrl *OrderController) WaffoWebhook(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if merchantExternalID == "" && gatewayOrderNo == "" && buyerEmail == "" {
|
||||
if merchantExternalID == "" && gatewayOrderNo == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "missing orderMerchantExternalId / orderId"})
|
||||
return
|
||||
}
|
||||
|
||||
amount := 0.0
|
||||
if v, ok := data["orderAmount"].(float64); ok {
|
||||
amount = v
|
||||
} else if v, ok := payload["amount"].(float64); ok {
|
||||
amount = v
|
||||
}
|
||||
currency := firstNonEmpty(asString(data["orderCurrency"]), asString(data["currency"]), asString(payload["currency"]))
|
||||
|
||||
req := dto.PaymentWebhookRequest{
|
||||
EventType: eventType,
|
||||
OrderNo: merchantExternalID,
|
||||
GatewayOrderNo: firstNonEmpty(gatewayOrderNo, paymentID),
|
||||
Status: mappedStatus,
|
||||
Amount: amount,
|
||||
Currency: currency,
|
||||
CustomerEmail: buyerEmail,
|
||||
Gateway: "waffo",
|
||||
Payload: string(body),
|
||||
|
||||
Reference in new issue
Block a user