From 7012279a269e779b3c954cb5a4477c4b33f3c7c0 Mon Sep 17 00:00:00 2001 From: Seefs Date: Thu, 23 Apr 2026 10:24:39 +0800 Subject: [PATCH 1/6] fix(payment): secure Waffo Pancake webhook mapping --- controller/topup_waffo_pancake.go | 50 +-- controller/topup_waffo_pancake_test.go | 35 ++- router/api-router.go | 6 +- service/waffo_pancake.go | 52 +++- service/waffo_pancake_test.go | 294 +++++++++++++++++- .../components/settings/PaymentSetting.jsx | 17 +- 6 files changed, 404 insertions(+), 50 deletions(-) diff --git a/controller/topup_waffo_pancake.go b/controller/topup_waffo_pancake.go index 81515a56ed3..678e3a5f7d3 100644 --- a/controller/topup_waffo_pancake.go +++ b/controller/topup_waffo_pancake.go @@ -53,9 +53,6 @@ func RequestWaffoPancakeAmount(c *gin.Context) { func getWaffoPancakePayMoney(amount int64, group string) float64 { dAmount := decimal.NewFromInt(amount) - if operation_setting.GetQuotaDisplayType() == operation_setting.QuotaDisplayTypeTokens { - dAmount = dAmount.Div(decimal.NewFromFloat(common.QuotaPerUnit)) - } topupGroupRatio := common.GetTopupGroupRatio(group) if topupGroupRatio == 0 { @@ -75,20 +72,6 @@ func getWaffoPancakePayMoney(amount int64, group string) float64 { return payMoney.InexactFloat64() } -func normalizeWaffoPancakeTopUpAmount(amount int64) int64 { - if operation_setting.GetQuotaDisplayType() != operation_setting.QuotaDisplayTypeTokens { - return amount - } - - normalized := decimal.NewFromInt(amount). - Div(decimal.NewFromFloat(common.QuotaPerUnit)). - IntPart() - if normalized < 1 { - return 1 - } - return normalized -} - func formatWaffoPancakeAmount(payMoney float64) string { return decimal.NewFromFloat(payMoney).StringFixed(2) } @@ -158,9 +141,10 @@ func RequestWaffoPancakePay(c *gin.Context) { } tradeNo := fmt.Sprintf("WAFFO_PANCAKE-%d-%d-%s", id, time.Now().UnixMilli(), randstr.String(6)) + currency := strings.ToUpper(strings.TrimSpace(setting.WaffoPancakeCurrency)) topUp := &model.TopUp{ UserId: id, - Amount: normalizeWaffoPancakeTopUpAmount(req.Amount), + Amount: req.Amount, Money: payMoney, TradeNo: tradeNo, PaymentMethod: model.PaymentMethodWaffoPancake, @@ -178,7 +162,7 @@ func RequestWaffoPancakePay(c *gin.Context) { StoreID: setting.WaffoPancakeStoreID, ProductID: setting.WaffoPancakeProductID, ProductType: "onetime", - Currency: strings.ToUpper(strings.TrimSpace(setting.WaffoPancakeCurrency)), + Currency: currency, PriceSnapshot: &service.WaffoPancakePriceSnapshot{ Amount: formatWaffoPancakeAmount(payMoney), TaxIncluded: false, @@ -187,6 +171,14 @@ func RequestWaffoPancakePay(c *gin.Context) { BuyerEmail: getWaffoPancakeBuyerEmail(user), SuccessURL: getWaffoPancakeReturnURL(), ExpiresInSeconds: &expiresInSeconds, + Metadata: buildWaffoPancakeOrderMetadata( + id, + tradeNo, + req.Amount, + req.Amount, + payMoney, + currency, + ), }) if err != nil { logger.LogError(c.Request.Context(), fmt.Sprintf("Waffo Pancake 创建结账会话失败 user_id=%d trade_no=%s error=%q", id, tradeNo, err.Error())) @@ -208,6 +200,26 @@ func RequestWaffoPancakePay(c *gin.Context) { }) } +func buildWaffoPancakeOrderMetadata(userID int, tradeNo string, requestedAmount int64, storedAmount int64, money float64, currency string) *service.WaffoPancakeOrderMetadata { + mode := "prod" + if setting.WaffoPancakeSandbox { + mode = "test" + } + + return &service.WaffoPancakeOrderMetadata{ + TradeNo: tradeNo, + UserID: userID, + PaymentMethod: model.PaymentMethodWaffoPancake, + RequestedAmount: requestedAmount, + StoredAmount: storedAmount, + Money: formatWaffoPancakeAmount(money), + Currency: currency, + StoreID: setting.WaffoPancakeStoreID, + ProductID: setting.WaffoPancakeProductID, + Mode: mode, + } +} + func WaffoPancakeWebhook(c *gin.Context) { if !isWaffoPancakeWebhookEnabled() { logger.LogWarn(c.Request.Context(), fmt.Sprintf("Waffo Pancake webhook 被拒绝 reason=webhook_disabled path=%q client_ip=%s", c.Request.RequestURI, c.ClientIP())) diff --git a/controller/topup_waffo_pancake_test.go b/controller/topup_waffo_pancake_test.go index 483dd7b793f..71c5ae4ef97 100644 --- a/controller/topup_waffo_pancake_test.go +++ b/controller/topup_waffo_pancake_test.go @@ -4,6 +4,7 @@ import ( "testing" "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/model" "github.com/QuantumNous/new-api/setting" "github.com/QuantumNous/new-api/setting/operation_setting" "github.com/stretchr/testify/require" @@ -27,6 +28,34 @@ func TestFormatWaffoPancakeAmount_UsesDisplayPriceString(t *testing.T) { } } +func TestBuildWaffoPancakeOrderMetadata(t *testing.T) { + originalSandbox := setting.WaffoPancakeSandbox + originalStoreID := setting.WaffoPancakeStoreID + originalProductID := setting.WaffoPancakeProductID + t.Cleanup(func() { + setting.WaffoPancakeSandbox = originalSandbox + setting.WaffoPancakeStoreID = originalStoreID + setting.WaffoPancakeProductID = originalProductID + }) + + setting.WaffoPancakeSandbox = true + setting.WaffoPancakeStoreID = "store_123" + setting.WaffoPancakeProductID = "product_456" + + metadata := buildWaffoPancakeOrderMetadata(42, "WAFFO_PANCAKE-42-123456-abc123", 1000, 10, 29, "USD") + + require.Equal(t, "WAFFO_PANCAKE-42-123456-abc123", metadata.TradeNo) + require.Equal(t, 42, metadata.UserID) + require.Equal(t, model.PaymentMethodWaffoPancake, metadata.PaymentMethod) + require.Equal(t, int64(1000), metadata.RequestedAmount) + require.Equal(t, int64(10), metadata.StoredAmount) + require.Equal(t, "29.00", metadata.Money) + require.Equal(t, "USD", metadata.Currency) + require.Equal(t, "store_123", metadata.StoreID) + require.Equal(t, "product_456", metadata.ProductID) + require.Equal(t, "test", metadata.Mode) +} + func TestGetWaffoPancakePayMoney(t *testing.T) { originalUnitPrice := setting.WaffoPancakeUnitPrice originalQuotaDisplayType := operation_setting.GetGeneralSetting().QuotaDisplayType @@ -66,11 +95,11 @@ func TestGetWaffoPancakePayMoney(t *testing.T) { expected: 24, }, { - name: "tokens display converts quota to display units before pricing", - amount: int64(common.QuotaPerUnit * 3), + name: "quota display type does not affect Waffo Pancake pricing", + amount: 10, group: "vip", quotaDisplayType: operation_setting.QuotaDisplayTypeTokens, - expected: 4.5, + expected: 24, }, { name: "non-positive discount falls back to no discount", diff --git a/router/api-router.go b/router/api-router.go index 83f5e4ae9d9..4696dc6009a 100644 --- a/router/api-router.go +++ b/router/api-router.go @@ -49,7 +49,7 @@ func SetApiRouter(router *gin.Engine) { apiRouter.POST("/stripe/webhook", controller.StripeWebhook) apiRouter.POST("/creem/webhook", controller.CreemWebhook) apiRouter.POST("/waffo/webhook", controller.WaffoWebhook) - //apiRouter.POST("/waffo-pancake/webhook", controller.WaffoPancakeWebhook) + apiRouter.POST("/waffo-pancake/webhook", controller.WaffoPancakeWebhook) // Universal secure verification routes apiRouter.POST("/verify", middleware.UserAuth(), middleware.CriticalRateLimit(), controller.UniversalVerify) @@ -93,8 +93,8 @@ func SetApiRouter(router *gin.Engine) { selfRoute.POST("/creem/pay", middleware.CriticalRateLimit(), controller.RequestCreemPay) selfRoute.POST("/waffo/amount", controller.RequestWaffoAmount) selfRoute.POST("/waffo/pay", middleware.CriticalRateLimit(), controller.RequestWaffoPay) - //selfRoute.POST("/waffo-pancake/amount", controller.RequestWaffoPancakeAmount) - //selfRoute.POST("/waffo-pancake/pay", middleware.CriticalRateLimit(), controller.RequestWaffoPancakePay) + selfRoute.POST("/waffo-pancake/amount", controller.RequestWaffoPancakeAmount) + selfRoute.POST("/waffo-pancake/pay", middleware.CriticalRateLimit(), controller.RequestWaffoPancakePay) selfRoute.POST("/aff_transfer", controller.TransferAffQuota) selfRoute.PUT("/setting", controller.UpdateUserSetting) diff --git a/service/waffo_pancake.go b/service/waffo_pancake.go index 9033c37f37d..6faf6101e89 100644 --- a/service/waffo_pancake.go +++ b/service/waffo_pancake.go @@ -35,6 +35,19 @@ type WaffoPancakePriceSnapshot struct { TaxCategory string `json:"taxCategory"` } +type WaffoPancakeOrderMetadata struct { + TradeNo string `json:"trade_no"` + UserID int `json:"user_id"` + PaymentMethod string `json:"payment_method"` + RequestedAmount int64 `json:"requested_amount"` + StoredAmount int64 `json:"stored_amount"` + Money string `json:"money"` + Currency string `json:"currency"` + StoreID string `json:"store_id"` + ProductID string `json:"product_id"` + Mode string `json:"mode"` +} + type WaffoPancakeCreateSessionParams struct { StoreID string `json:"storeId"` ProductID string `json:"productId"` @@ -44,6 +57,7 @@ type WaffoPancakeCreateSessionParams struct { BuyerEmail string `json:"buyerEmail,omitempty"` SuccessURL string `json:"successUrl,omitempty"` ExpiresInSeconds *int `json:"expiresInSeconds,omitempty"` + Metadata *WaffoPancakeOrderMetadata `json:"metadata,omitempty"` } type WaffoPancakeCheckoutSession struct { @@ -64,13 +78,14 @@ type waffoPancakeCreateSessionResponse struct { } type waffoPancakeWebhookData struct { - ID string `json:"id"` - OrderID string `json:"orderId"` - BuyerEmail string `json:"buyerEmail"` - Currency string `json:"currency"` - Amount dto.StringValue `json:"amount"` - TaxAmount dto.StringValue `json:"taxAmount"` - ProductName string `json:"productName"` + ID string `json:"id"` + OrderID string `json:"orderId"` + BuyerEmail string `json:"buyerEmail"` + Currency string `json:"currency"` + Amount dto.StringValue `json:"amount"` + TaxAmount dto.StringValue `json:"taxAmount"` + ProductName string `json:"productName"` + OrderMetadata WaffoPancakeOrderMetadata `json:"orderMetadata"` } type waffoPancakeWebhookEvent struct { @@ -165,15 +180,24 @@ func ResolveWaffoPancakeTradeNo(event *waffoPancakeWebhookEvent) (string, error) return "", fmt.Errorf("missing webhook event") } - if tradeNo := strings.TrimSpace(event.Data.OrderID); tradeNo != "" { - topUp := model.GetTopUpByTradeNo(tradeNo) - if topUp != nil && topUp.PaymentMethod == model.PaymentMethodWaffoPancake { - return tradeNo, nil - } - return "", fmt.Errorf("waffo pancake order not found for webhook orderId=%s", tradeNo) + metadata := event.Data.OrderMetadata + tradeNo := strings.TrimSpace(metadata.TradeNo) + if tradeNo == "" { + return "", fmt.Errorf("missing webhook orderMetadata.trade_no") + } + + topUp := model.GetTopUpByTradeNo(tradeNo) + if topUp == nil { + return "", fmt.Errorf("waffo pancake order not found for metadata trade_no=%s", tradeNo) + } + if topUp.PaymentMethod != model.PaymentMethodWaffoPancake { + return "", fmt.Errorf("waffo pancake payment method mismatch trade_no=%s", tradeNo) + } + if metadata.UserID != topUp.UserId { + return "", fmt.Errorf("webhook orderMetadata.user_id mismatch") } - return "", fmt.Errorf("missing webhook orderId") + return tradeNo, nil } func normalizeRSAPrivateKey(raw string) (string, error) { diff --git a/service/waffo_pancake_test.go b/service/waffo_pancake_test.go index eeb1012b076..47e1007f389 100644 --- a/service/waffo_pancake_test.go +++ b/service/waffo_pancake_test.go @@ -1,7 +1,15 @@ package service import ( + "crypto" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "encoding/pem" "fmt" + "strconv" "strings" "testing" "time" @@ -56,7 +64,7 @@ func TestWaffoPancakeCreateSessionResponseParsesDocumentedPayload(t *testing.T) require.Empty(t, result.Data.OrderID) } -func TestResolveWaffoPancakeTradeNo_UsesWebhookOrderIDWhenLocalOrderExists(t *testing.T) { +func TestResolveWaffoPancakeTradeNo_RejectsWebhookOrderIDOnly(t *testing.T) { db := setupWaffoPancakeTestDB(t) topUp := &model.TopUp{ @@ -75,11 +83,116 @@ func TestResolveWaffoPancakeTradeNo_UsesWebhookOrderIDWhenLocalOrderExists(t *te OrderID: "ORD_5dXBtmF2HLlHfbPNm0Wcnz", }, }) + require.Error(t, err) + require.Empty(t, tradeNo) +} + +func TestResolveWaffoPancakeTradeNo_UsesOrderMetadataTradeNo(t *testing.T) { + db := setupWaffoPancakeTestDB(t) + + topUp := &model.TopUp{ + UserId: 42, + Amount: 10, + Money: 29, + TradeNo: "WAFFO_PANCAKE-42-123456-abc123", + PaymentMethod: model.PaymentMethodWaffoPancake, + CreateTime: time.Now().Unix(), + Status: common.TopUpStatusPending, + } + require.NoError(t, db.Create(topUp).Error) + + var event waffoPancakeWebhookEvent + require.NoError(t, common.Unmarshal([]byte(`{ + "storeId": "store_123", + "mode": "test", + "data": { + "orderId": "ORD_remote_123", + "currency": "USD", + "amount": "29.00", + "orderMetadata": { + "trade_no": "WAFFO_PANCAKE-42-123456-abc123", + "user_id": 42, + "payment_method": "waffo_pancake", + "stored_amount": 10, + "money": "29.00", + "currency": "USD", + "store_id": "store_123", + "product_id": "product_456", + "mode": "test" + } + } + }`), &event)) + + tradeNo, err := ResolveWaffoPancakeTradeNo(&event) require.NoError(t, err) - require.Equal(t, "ORD_5dXBtmF2HLlHfbPNm0Wcnz", tradeNo) + require.Equal(t, "WAFFO_PANCAKE-42-123456-abc123", tradeNo) +} + +func TestResolveWaffoPancakeTradeNo_RejectsMetadataUserMismatch(t *testing.T) { + db := setupWaffoPancakeTestDB(t) + + topUp := &model.TopUp{ + UserId: 42, + Amount: 10, + Money: 29, + TradeNo: "WAFFO_PANCAKE-42-123456-abc123", + PaymentMethod: model.PaymentMethodWaffoPancake, + CreateTime: time.Now().Unix(), + Status: common.TopUpStatusPending, + } + require.NoError(t, db.Create(topUp).Error) + + var event waffoPancakeWebhookEvent + require.NoError(t, common.Unmarshal([]byte(`{ + "storeId": "store_123", + "mode": "test", + "data": { + "orderId": "WAFFO_PANCAKE-42-123456-abc123", + "currency": "USD", + "amount": "29.00", + "orderMetadata": { + "trade_no": "WAFFO_PANCAKE-42-123456-abc123", + "user_id": 99, + "payment_method": "waffo_pancake", + "stored_amount": 10, + "money": "29.00", + "currency": "USD", + "store_id": "store_123", + "product_id": "product_456", + "mode": "test" + } + } + }`), &event)) + + tradeNo, err := ResolveWaffoPancakeTradeNo(&event) + require.Error(t, err) + require.Empty(t, tradeNo) +} + +func TestResolveWaffoPancakeTradeNo_RejectsWebhookWithoutMetadata(t *testing.T) { + db := setupWaffoPancakeTestDB(t) + + topUp := &model.TopUp{ + UserId: 42, + Amount: 10, + Money: 29, + TradeNo: "WAFFO_PANCAKE-42-123456-abc123", + PaymentMethod: model.PaymentMethodWaffoPancake, + CreateTime: time.Now().Unix(), + Status: common.TopUpStatusPending, + } + require.NoError(t, db.Create(topUp).Error) + + tradeNo, err := ResolveWaffoPancakeTradeNo(&waffoPancakeWebhookEvent{ + Data: waffoPancakeWebhookData{ + OrderID: "WAFFO_PANCAKE-42-123456-abc123", + }, + }) + require.Error(t, err) + require.Empty(t, tradeNo) } -func TestResolveWaffoPancakeTradeNo_FailsWhenWebhookOrderIDIsUnknown(t *testing.T) { +func TestResolveWaffoPancakeTradeNo_FailsWhenMetadataTradeNoIsUnknown(t *testing.T) { db := setupWaffoPancakeTestDB(t) user := &model.User{ @@ -102,10 +215,24 @@ func TestResolveWaffoPancakeTradeNo_FailsWhenWebhookOrderIDIsUnknown(t *testing. require.NoError(t, db.Create(topUp).Error) tradeNo, err := ResolveWaffoPancakeTradeNo(&waffoPancakeWebhookEvent{ + StoreID: "store_123", + Mode: "test", Data: waffoPancakeWebhookData{ - OrderID: "ORD_unknown", + OrderID: "ORD_remote_123", BuyerEmail: user.Email, + Currency: "USD", Amount: "29.00", + OrderMetadata: WaffoPancakeOrderMetadata{ + TradeNo: "WAFFO_PANCAKE-42-unknown", + UserID: 42, + PaymentMethod: model.PaymentMethodWaffoPancake, + StoredAmount: int64(10), + Money: "29.00", + Currency: "USD", + StoreID: "store_123", + ProductID: "product_456", + Mode: "test", + }, }, }) require.Error(t, err) @@ -155,3 +282,162 @@ func TestResolveWaffoPancakeWebhookEnvironment(t *testing.T) { }) } } + +func TestVerifyConfiguredWaffoPancakeWebhook_VerifiesXWaffoSignature(t *testing.T) { + privateKey, publicKey := generateWaffoPancakeWebhookKeyPair(t) + restoreWaffoPancakeWebhookSettings(t) + setting.WaffoPancakeSandbox = false + setting.WaffoPancakeWebhookPublicKey = publicKey + + payload := testWaffoPancakeWebhookPayload() + signature := signWaffoPancakeWebhookPayload(t, payload, privateKey, strconv.FormatInt(time.Now().UnixMilli(), 10)) + + event, err := VerifyConfiguredWaffoPancakeWebhook(payload, signature) + require.NoError(t, err) + require.NotNil(t, event) + require.Equal(t, "order.completed", event.EventType) + require.Equal(t, "WAFFO_PANCAKE-42-123456-abc123", event.Data.OrderMetadata.TradeNo) +} + +func TestVerifyConfiguredWaffoPancakeWebhook_RejectsMissingSignature(t *testing.T) { + _, publicKey := generateWaffoPancakeWebhookKeyPair(t) + restoreWaffoPancakeWebhookSettings(t) + setting.WaffoPancakeSandbox = false + setting.WaffoPancakeWebhookPublicKey = publicKey + + event, err := VerifyConfiguredWaffoPancakeWebhook(testWaffoPancakeWebhookPayload(), "") + require.Error(t, err) + require.Nil(t, event) + require.Contains(t, err.Error(), "missing X-Waffo-Signature header") +} + +func TestVerifyConfiguredWaffoPancakeWebhook_RejectsTamperedPayload(t *testing.T) { + privateKey, publicKey := generateWaffoPancakeWebhookKeyPair(t) + restoreWaffoPancakeWebhookSettings(t) + setting.WaffoPancakeSandbox = false + setting.WaffoPancakeWebhookPublicKey = publicKey + + payload := testWaffoPancakeWebhookPayload() + signature := signWaffoPancakeWebhookPayload(t, payload, privateKey, strconv.FormatInt(time.Now().UnixMilli(), 10)) + tamperedPayload := strings.Replace(payload, "WAFFO_PANCAKE-42-123456-abc123", "WAFFO_PANCAKE-42-tampered", 1) + + event, err := VerifyConfiguredWaffoPancakeWebhook(tamperedPayload, signature) + require.Error(t, err) + require.Nil(t, event) + require.Contains(t, err.Error(), "invalid webhook signature") +} + +func TestVerifyConfiguredWaffoPancakeWebhook_RejectsWrongWebhookPublicKey(t *testing.T) { + privateKey, _ := generateWaffoPancakeWebhookKeyPair(t) + _, wrongPublicKey := generateWaffoPancakeWebhookKeyPair(t) + restoreWaffoPancakeWebhookSettings(t) + setting.WaffoPancakeSandbox = false + setting.WaffoPancakeWebhookPublicKey = wrongPublicKey + + payload := testWaffoPancakeWebhookPayload() + signature := signWaffoPancakeWebhookPayload(t, payload, privateKey, strconv.FormatInt(time.Now().UnixMilli(), 10)) + + event, err := VerifyConfiguredWaffoPancakeWebhook(payload, signature) + require.Error(t, err) + require.Nil(t, event) + require.Contains(t, err.Error(), "invalid webhook signature") +} + +func TestVerifyConfiguredWaffoPancakeWebhook_RejectsInvalidWebhookPublicKey(t *testing.T) { + privateKey, _ := generateWaffoPancakeWebhookKeyPair(t) + restoreWaffoPancakeWebhookSettings(t) + setting.WaffoPancakeSandbox = false + setting.WaffoPancakeWebhookPublicKey = "not-a-public-key" + + payload := testWaffoPancakeWebhookPayload() + signature := signWaffoPancakeWebhookPayload(t, payload, privateKey, strconv.FormatInt(time.Now().UnixMilli(), 10)) + + event, err := VerifyConfiguredWaffoPancakeWebhook(payload, signature) + require.Error(t, err) + require.Nil(t, event) + require.Contains(t, err.Error(), "invalid webhook signature") +} + +func TestVerifyConfiguredWaffoPancakeWebhook_RejectsExpiredTimestamp(t *testing.T) { + privateKey, publicKey := generateWaffoPancakeWebhookKeyPair(t) + restoreWaffoPancakeWebhookSettings(t) + setting.WaffoPancakeSandbox = false + setting.WaffoPancakeWebhookPublicKey = publicKey + + payload := testWaffoPancakeWebhookPayload() + oldTimestamp := strconv.FormatInt(time.Now().Add(-waffoPancakeDefaultTolerance-time.Minute).UnixMilli(), 10) + signature := signWaffoPancakeWebhookPayload(t, payload, privateKey, oldTimestamp) + + event, err := VerifyConfiguredWaffoPancakeWebhook(payload, signature) + require.Error(t, err) + require.Nil(t, event) + require.Contains(t, err.Error(), "webhook timestamp outside tolerance window") +} + +func generateWaffoPancakeWebhookKeyPair(t *testing.T) (*rsa.PrivateKey, string) { + t.Helper() + + privateKey, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + + publicKeyDER, err := x509.MarshalPKIXPublicKey(&privateKey.PublicKey) + require.NoError(t, err) + + publicKeyPEM := pem.EncodeToMemory(&pem.Block{ + Type: "PUBLIC KEY", + Bytes: publicKeyDER, + }) + require.NotEmpty(t, publicKeyPEM) + + return privateKey, string(publicKeyPEM) +} + +func signWaffoPancakeWebhookPayload(t *testing.T, payload string, privateKey *rsa.PrivateKey, timestamp string) string { + t.Helper() + + signatureInput := fmt.Sprintf("%s.%s", timestamp, payload) + digest := sha256.Sum256([]byte(signatureInput)) + signature, err := rsa.SignPKCS1v15(rand.Reader, privateKey, crypto.SHA256, digest[:]) + require.NoError(t, err) + + return fmt.Sprintf("t=%s,v1=%s", timestamp, base64.StdEncoding.EncodeToString(signature)) +} + +func restoreWaffoPancakeWebhookSettings(t *testing.T) { + t.Helper() + + originalSandbox := setting.WaffoPancakeSandbox + originalWebhookPublicKey := setting.WaffoPancakeWebhookPublicKey + originalWebhookTestKey := setting.WaffoPancakeWebhookTestKey + t.Cleanup(func() { + setting.WaffoPancakeSandbox = originalSandbox + setting.WaffoPancakeWebhookPublicKey = originalWebhookPublicKey + setting.WaffoPancakeWebhookTestKey = originalWebhookTestKey + }) +} + +func testWaffoPancakeWebhookPayload() string { + return `{ + "id": "evt_123", + "eventType": "order.completed", + "storeId": "store_123", + "mode": "prod", + "data": { + "orderId": "ORD_remote_123", + "currency": "USD", + "amount": "29.00", + "orderMetadata": { + "trade_no": "WAFFO_PANCAKE-42-123456-abc123", + "user_id": 42, + "payment_method": "waffo_pancake", + "requested_amount": 10, + "stored_amount": 10, + "money": "29.00", + "currency": "USD", + "store_id": "store_123", + "product_id": "product_456", + "mode": "prod" + } + } + }` +} diff --git a/web/src/components/settings/PaymentSetting.jsx b/web/src/components/settings/PaymentSetting.jsx index 080c3e6e0ba..10db23f2945 100644 --- a/web/src/components/settings/PaymentSetting.jsx +++ b/web/src/components/settings/PaymentSetting.jsx @@ -198,13 +198,16 @@ const PaymentSetting = () => { hideSectionTitle /> - {/**/} - {/* */} - {/**/} + + + From 728c9376fa01ca1a4d1bd5e66b0a51be08835c4a Mon Sep 17 00:00:00 2001 From: Seefs Date: Thu, 23 Apr 2026 10:49:33 +0800 Subject: [PATCH 2/6] fix(payment): add audit info for Waffo Pancake topups --- controller/topup_waffo_pancake.go | 2 +- model/payment_method_guard_test.go | 27 ++++++++++++++++++++++++++- model/topup.go | 4 ++-- 3 files changed, 29 insertions(+), 4 deletions(-) diff --git a/controller/topup_waffo_pancake.go b/controller/topup_waffo_pancake.go index 678e3a5f7d3..6a6165c3950 100644 --- a/controller/topup_waffo_pancake.go +++ b/controller/topup_waffo_pancake.go @@ -260,7 +260,7 @@ func WaffoPancakeWebhook(c *gin.Context) { LockOrder(tradeNo) defer UnlockOrder(tradeNo) - if err := model.RechargeWaffoPancake(tradeNo); err != nil { + if err := model.RechargeWaffoPancake(tradeNo, c.ClientIP()); err != nil { logger.LogError(c.Request.Context(), fmt.Sprintf("Waffo Pancake 充值处理失败 trade_no=%s event_id=%s order_id=%s client_ip=%s error=%q", tradeNo, event.ID, event.Data.OrderID, c.ClientIP(), err.Error())) c.String(http.StatusInternalServerError, "retry") return diff --git a/model/payment_method_guard_test.go b/model/payment_method_guard_test.go index 9bc292444fe..8824298c287 100644 --- a/model/payment_method_guard_test.go +++ b/model/payment_method_guard_test.go @@ -91,7 +91,7 @@ func TestRechargeWaffoPancake_RejectsMismatchedPaymentMethod(t *testing.T) { insertUserForPaymentGuardTest(t, 101, 0) insertTopUpForPaymentGuardTest(t, "waffo-pancake-guard", 101, PaymentMethodStripe) - err := RechargeWaffoPancake("waffo-pancake-guard") + err := RechargeWaffoPancake("waffo-pancake-guard", "203.0.113.10") require.Error(t, err) topUp := GetTopUpByTradeNo("waffo-pancake-guard") @@ -100,6 +100,31 @@ func TestRechargeWaffoPancake_RejectsMismatchedPaymentMethod(t *testing.T) { assert.Equal(t, 0, getUserQuotaForPaymentGuardTest(t, 101)) } +func TestRechargeWaffoPancake_RecordsTopupAuditInfo(t *testing.T) { + truncateTables(t) + + insertUserForPaymentGuardTest(t, 102, 0) + insertTopUpForPaymentGuardTest(t, "waffo-pancake-audit", 102, PaymentMethodWaffoPancake) + + err := RechargeWaffoPancake("waffo-pancake-audit", "203.0.113.11") + require.NoError(t, err) + + var log Log + require.NoError(t, LOG_DB.Where("user_id = ? AND type = ?", 102, LogTypeTopup).First(&log).Error) + assert.Equal(t, "203.0.113.11", log.Ip) + + var other map[string]any + require.NoError(t, common.Unmarshal([]byte(log.Other), &other)) + adminInfo, ok := other["admin_info"].(map[string]any) + require.True(t, ok) + assert.Equal(t, "203.0.113.11", adminInfo["caller_ip"]) + assert.Equal(t, PaymentMethodWaffoPancake, adminInfo["payment_method"]) + assert.Equal(t, PaymentMethodWaffoPancake, adminInfo["callback_payment_method"]) + assert.NotEmpty(t, adminInfo["server_ip"]) + assert.NotNil(t, adminInfo["node_name"]) + assert.NotEmpty(t, adminInfo["version"]) +} + func TestUpdatePendingTopUpStatus_RejectsMismatchedPaymentMethod(t *testing.T) { testCases := []struct { name string diff --git a/model/topup.go b/model/topup.go index c1ac663f759..669d739eb5e 100644 --- a/model/topup.go +++ b/model/topup.go @@ -516,7 +516,7 @@ func RechargeWaffo(tradeNo string, callerIp string) (err error) { return nil } -func RechargeWaffoPancake(tradeNo string) (err error) { +func RechargeWaffoPancake(tradeNo string, callerIp string) (err error) { if tradeNo == "" { return errors.New("未提供支付单号") } @@ -571,7 +571,7 @@ func RechargeWaffoPancake(tradeNo string) (err error) { } if quotaToAdd > 0 { - RecordLog(topUp.UserId, LogTypeTopup, fmt.Sprintf("Waffo Pancake充值成功,充值额度: %v,支付金额: %.2f", logger.FormatQuota(quotaToAdd), topUp.Money)) + RecordTopupLog(topUp.UserId, fmt.Sprintf("Waffo Pancake充值成功,充值额度: %v,支付金额: %.2f", logger.FormatQuota(quotaToAdd), topUp.Money), callerIp, topUp.PaymentMethod, PaymentMethodWaffoPancake) } return nil From b2eeb0e85ad7418ab389b37a6b275db73d11541c Mon Sep 17 00:00:00 2001 From: Seefs Date: Thu, 23 Apr 2026 10:51:57 +0800 Subject: [PATCH 3/6] chore(ui): use Waffo Pancake logo for topup option --- web/public/waffo-pancake-logo.svg | 21 +++++++++++++++++++++ web/src/components/topup/RechargeCard.jsx | 16 +++++++++++----- 2 files changed, 32 insertions(+), 5 deletions(-) create mode 100644 web/public/waffo-pancake-logo.svg diff --git a/web/public/waffo-pancake-logo.svg b/web/public/waffo-pancake-logo.svg new file mode 100644 index 00000000000..3d26e1114fa --- /dev/null +++ b/web/public/waffo-pancake-logo.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/web/src/components/topup/RechargeCard.jsx b/web/src/components/topup/RechargeCard.jsx index 1e2923cafc1..04928567aff 100644 --- a/web/src/components/topup/RechargeCard.jsx +++ b/web/src/components/topup/RechargeCard.jsx @@ -51,6 +51,7 @@ import { getCurrencyConfig } from '../../helpers/render'; import SubscriptionPlansCard from './SubscriptionPlansCard'; const { Text } = Typography; +const WAFFO_PANCAKE_LOGO = '/waffo-pancake-logo.svg'; const RechargeCard = ({ t, @@ -343,6 +344,16 @@ const RechargeCard = ({ ) : payMethod.type === 'stripe' ? ( + ) : payMethod.type === 'waffo_pancake' ? ( + Waffo Pancake ) : payMethod.icon ? ( - ) : payMethod.type === 'waffo_pancake' ? ( - ) : ( Date: Thu, 23 Apr 2026 11:01:41 +0800 Subject: [PATCH 4/6] fix(ui): enlarge Waffo Pancake topup icon --- web/src/components/topup/RechargeCard.jsx | 35 +++++++++++++++++------ 1 file changed, 26 insertions(+), 9 deletions(-) diff --git a/web/src/components/topup/RechargeCard.jsx b/web/src/components/topup/RechargeCard.jsx index 04928567aff..8cfa2047b94 100644 --- a/web/src/components/topup/RechargeCard.jsx +++ b/web/src/components/topup/RechargeCard.jsx @@ -52,6 +52,22 @@ import SubscriptionPlansCard from './SubscriptionPlansCard'; const { Text } = Typography; const WAFFO_PANCAKE_LOGO = '/waffo-pancake-logo.svg'; +const WAFFO_PANCAKE_ICON_BOX_STYLE = { + display: 'inline-flex', + width: 18, + height: 18, + overflow: 'hidden', + alignItems: 'center', + justifyContent: 'flex-start', + flex: '0 0 18px', +}; +const WAFFO_PANCAKE_ICON_IMAGE_STYLE = { + display: 'block', + height: 22, + width: 'auto', + maxWidth: 'none', + transform: 'translateY(-2px)', +}; const RechargeCard = ({ t, @@ -345,15 +361,16 @@ const RechargeCard = ({ ) : payMethod.type === 'stripe' ? ( ) : payMethod.type === 'waffo_pancake' ? ( - Waffo Pancake + + + ) : payMethod.icon ? ( Date: Thu, 23 Apr 2026 11:04:18 +0800 Subject: [PATCH 5/6] fix(payment): enforce Waffo Pancake webhook mode --- service/waffo_pancake.go | 10 ++++++++++ service/waffo_pancake_test.go | 15 +++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/service/waffo_pancake.go b/service/waffo_pancake.go index 6faf6101e89..6e189274059 100644 --- a/service/waffo_pancake.go +++ b/service/waffo_pancake.go @@ -172,6 +172,9 @@ func CreateWaffoPancakeCheckoutSession(ctx context.Context, params *WaffoPancake func VerifyConfiguredWaffoPancakeWebhook(payload string, signatureHeader string) (*waffoPancakeWebhookEvent, error) { environment := resolveWaffoPancakeWebhookEnvironment(payload) + if !isWaffoPancakeWebhookEnvironmentAllowed(environment) { + return nil, fmt.Errorf("webhook environment mismatch") + } return verifyWaffoPancakeWebhook(payload, signatureHeader, environment) } @@ -376,6 +379,13 @@ func resolveWaffoPancakeWebhookPublicKey(environment string) string { return strings.TrimSpace(setting.WaffoPancakeWebhookTestKey) } +func isWaffoPancakeWebhookEnvironmentAllowed(environment string) bool { + if setting.WaffoPancakeSandbox { + return environment == "test" + } + return environment == "prod" +} + func verifyWaffoPancakeWebhookWithKey(signatureInput string, signaturePart string, rawPublicKey string) error { publicKeyPEM, err := normalizeRSAPublicKey(rawPublicKey) if err != nil { diff --git a/service/waffo_pancake_test.go b/service/waffo_pancake_test.go index 47e1007f389..6f798ef2662 100644 --- a/service/waffo_pancake_test.go +++ b/service/waffo_pancake_test.go @@ -374,6 +374,21 @@ func TestVerifyConfiguredWaffoPancakeWebhook_RejectsExpiredTimestamp(t *testing. require.Contains(t, err.Error(), "webhook timestamp outside tolerance window") } +func TestVerifyConfiguredWaffoPancakeWebhook_RejectsSandboxModeMismatch(t *testing.T) { + privateKey, publicKey := generateWaffoPancakeWebhookKeyPair(t) + restoreWaffoPancakeWebhookSettings(t) + setting.WaffoPancakeSandbox = false + setting.WaffoPancakeWebhookTestKey = publicKey + + payload := strings.Replace(testWaffoPancakeWebhookPayload(), `"mode": "prod"`, `"mode": "test"`, 2) + signature := signWaffoPancakeWebhookPayload(t, payload, privateKey, strconv.FormatInt(time.Now().UnixMilli(), 10)) + + event, err := VerifyConfiguredWaffoPancakeWebhook(payload, signature) + require.Error(t, err) + require.Nil(t, event) + require.Contains(t, err.Error(), "webhook environment mismatch") +} + func generateWaffoPancakeWebhookKeyPair(t *testing.T) (*rsa.PrivateKey, string) { t.Helper() From 14fa7cd0b8f20208593b58491f933d77105afac6 Mon Sep 17 00:00:00 2001 From: Seefs Date: Sat, 16 May 2026 15:22:25 +0800 Subject: [PATCH 6/6] fix(web): lock Waffo settings behind compliance confirmation --- .../integrations/payment-settings-section.tsx | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/web/default/src/features/system-settings/integrations/payment-settings-section.tsx b/web/default/src/features/system-settings/integrations/payment-settings-section.tsx index e53721f0657..1e1636491f7 100644 --- a/web/default/src/features/system-settings/integrations/payment-settings-section.tsx +++ b/web/default/src/features/system-settings/integrations/payment-settings-section.tsx @@ -1468,11 +1468,18 @@ export function PaymentSettingsSection({ - - - - - +
+ + + + + +
{/* eslint-enable react-hooks/refs */} )