fix: 按评审报告止血支付漏洞并加固授权链路

废除浏览器回跳自助结算,通用 webhook 强制密钥鉴权,Waffo 支持验签与未知事件忽略;重构订单状态机/激活占座/SQLite WAL,并补备份、readyz、限流与前端导出/登录页安全修复。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Logan.LiandCursor committed 2026-08-12 21:26:58 +08:00
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),