fix: reject private callback and rate API URLs

This commit is contained in:
line-6000
2026-05-26 20:54:41 +08:00
parent 0d97237c71
commit 8bf2adad89
7 changed files with 267 additions and 20 deletions
@@ -10,6 +10,7 @@ import (
"github.com/GMWalletApp/epusdt/model/data"
"github.com/GMWalletApp/epusdt/model/mdb"
"github.com/GMWalletApp/epusdt/telegram"
"github.com/GMWalletApp/epusdt/util/security"
"github.com/labstack/echo/v4"
)
@@ -204,6 +205,13 @@ func normalizeAndValidateSettingItem(group, key, value string) (string, error) {
return value, err
}
return normalized, nil
case mdb.SettingKeyRateApiUrl:
if strings.ToLower(strings.TrimSpace(group)) != mdb.SettingGroupRate {
return value, fmt.Errorf("%s must use group %s", key, mdb.SettingGroupRate)
}
if err := security.ValidatePublicHTTPURL(value); err != nil {
return value, fmt.Errorf("%s invalid: %w", key, err)
}
}
return value, nil
}
+10 -4
View File
@@ -17,6 +17,7 @@ import (
"github.com/GMWalletApp/epusdt/util/constant"
"github.com/GMWalletApp/epusdt/util/log"
"github.com/GMWalletApp/epusdt/util/math"
"github.com/GMWalletApp/epusdt/util/security"
"github.com/dromara/carbon/v2"
"github.com/shopspring/decimal"
)
@@ -54,12 +55,17 @@ func normalizeOrderAddressByNetwork(network, address string) string {
// CreateTransaction creates a new payment order.
func CreateTransaction(req *request.CreateTransactionRequest, apiKey *mdb.ApiKey) (*response.CreateTransactionResponse, error) {
gCreateTransactionLock.Lock()
defer gCreateTransactionLock.Unlock()
token := strings.ToUpper(strings.TrimSpace(req.Token))
currency := strings.ToUpper(strings.TrimSpace(req.Currency))
network := strings.ToLower(strings.TrimSpace(req.Network))
notifyURL := strings.TrimSpace(req.NotifyUrl)
if err := security.ValidatePublicHTTPURL(notifyURL); err != nil {
return nil, err
}
gCreateTransactionLock.Lock()
defer gCreateTransactionLock.Unlock()
amountPrecision := data.GetAmountPrecision()
payAmount := math.MustParsePrecFloat64(req.Amount, amountPrecision)
rate := config.GetRateForCoin(strings.ToLower(token), strings.ToLower(currency))
@@ -116,7 +122,7 @@ func CreateTransaction(req *request.CreateTransactionRequest, apiKey *mdb.ApiKey
Token: token,
Network: network,
Status: mdb.StatusWaitPay,
NotifyUrl: req.NotifyUrl,
NotifyUrl: notifyURL,
RedirectUrl: req.RedirectUrl,
Name: req.Name,
PaymentType: req.PaymentType,
+10 -1
View File
@@ -26,7 +26,7 @@ func newCreateTransactionRequest(orderID string, amount float64) *request.Create
Token: "USDT",
Network: "tron",
Amount: amount,
NotifyUrl: "https://merchant.example/callback",
NotifyUrl: "https://93.184.216.34/callback",
}
}
@@ -50,6 +50,15 @@ func installMockHTTPClient(t *testing.T, handler roundTripFunc) {
})
}
func TestCreateTransactionRejectsPrivateNotifyURL(t *testing.T) {
req := newCreateTransactionRequest("order_private_notify_url", 1)
req.NotifyUrl = "http://127.0.0.1/notify"
if _, err := CreateTransaction(req, nil); err == nil {
t.Fatal("CreateTransaction returned nil error for private notify_url")
}
}
func TestCreateTransactionAssignsIncrementedAmountsAndLocks(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
+45 -4
View File
@@ -1035,12 +1035,53 @@ func TestAdminSettings_DeleteNonExistent(t *testing.T) {
}
}
func TestAdminSettings_RejectsPrivateRateAPIURL(t *testing.T) {
e, token := setupAdminTestEnv(t)
rec := doPutAdmin(e, "/admin/api/v1/settings", map[string]interface{}{
"items": []map[string]interface{}{
{"group": "rate", "key": mdb.SettingKeyRateApiUrl, "value": "http://127.0.0.1:8080/", "type": "string"},
},
}, token)
resp := assertOK(t, rec)
results, ok := resp["data"].([]interface{})
if !ok || len(results) != 1 {
t.Fatalf("expected one result, got %T %v", resp["data"], resp["data"])
}
result, _ := results[0].(map[string]interface{})
if result["ok"] != false {
t.Fatalf("private rate.api_url result = %v, want ok=false", result)
}
if got, _ := result["error"].(string); !strings.Contains(got, "rate.api_url invalid") {
t.Fatalf("private rate.api_url error = %q", got)
}
}
func TestAdminSettings_AllowsPublicRateAPIURL(t *testing.T) {
e, token := setupAdminTestEnv(t)
rec := doPutAdmin(e, "/admin/api/v1/settings", map[string]interface{}{
"items": []map[string]interface{}{
{"group": "rate", "key": mdb.SettingKeyRateApiUrl, "value": "https://93.184.216.34/rate", "type": "string"},
},
}, token)
resp := assertOK(t, rec)
results, ok := resp["data"].([]interface{})
if !ok || len(results) != 1 {
t.Fatalf("expected one result, got %T %v", resp["data"], resp["data"])
}
result, _ := results[0].(map[string]interface{})
if result["ok"] != true {
t.Fatalf("public rate.api_url result = %v, want ok=true", result)
}
}
func TestAdminSettings_DeleteThenReupsertRestoresSetting(t *testing.T) {
e, token := setupAdminTestEnv(t)
rec := doPutAdmin(e, "/admin/api/v1/settings", map[string]interface{}{
"items": []map[string]interface{}{
{"group": "rate", "key": mdb.SettingKeyRateApiUrl, "value": "https://rate.old.example", "type": "string"},
{"group": "rate", "key": mdb.SettingKeyRateApiUrl, "value": "https://93.184.216.34/rate-old", "type": "string"},
},
}, token)
assertOK(t, rec)
@@ -1053,7 +1094,7 @@ func TestAdminSettings_DeleteThenReupsertRestoresSetting(t *testing.T) {
rec = doPutAdmin(e, "/admin/api/v1/settings", map[string]interface{}{
"items": []map[string]interface{}{
{"group": "rate", "key": mdb.SettingKeyRateApiUrl, "value": "https://rate.new.example", "type": "string"},
{"group": "rate", "key": mdb.SettingKeyRateApiUrl, "value": "https://93.184.216.34/rate-new", "type": "string"},
},
}, token)
assertOK(t, rec)
@@ -1069,7 +1110,7 @@ func TestAdminSettings_DeleteThenReupsertRestoresSetting(t *testing.T) {
item, _ := row.(map[string]interface{})
if item["key"] == mdb.SettingKeyRateApiUrl {
found = true
if item["value"] != "https://rate.new.example" {
if item["value"] != "https://93.184.216.34/rate-new" {
t.Fatalf("rate.api_url value = %v, want new value", item["value"])
}
}
@@ -1350,7 +1391,7 @@ func TestAdminOrders_ListWithSubExcludesSubOrdersFromTopLevel(t *testing.T) {
"currency": "CNY",
"token": "USDT",
"network": "tron",
"notify_url": "https://merchant.example/callback",
"notify_url": "https://93.184.216.34/callback",
"redirect_url": "https://merchant.example/redirect",
})
rec := doPost(e, "/payments/gmpay/v1/order/create-transaction", parentBody)
+29 -11
View File
@@ -240,7 +240,7 @@ func TestCreateOrderGmpayV1Solana(t *testing.T) {
"token": "usdt",
"currency": "cny",
"network": "solana",
"notify_url": "http://localhost/notify",
"notify_url": "https://93.184.216.34/notify",
})
rec := doPost(e, "/payments/gmpay/v1/order/create-transaction", body)
@@ -269,6 +269,24 @@ func TestCreateOrderGmpayV1Solana(t *testing.T) {
t.Logf("Order created: trade_id=%v address=%v amount=%v", data["trade_id"], data["receive_address"], data["actual_amount"])
}
func TestCreateOrderGmpayRejectsPrivateNotifyURL(t *testing.T) {
e := setupTestEnv(t)
body := signBody(map[string]interface{}{
"order_id": "test-private-notify-001",
"amount": 1.00,
"token": "usdt",
"currency": "cny",
"network": "solana",
"notify_url": "http://127.0.0.1/notify",
})
rec := doPost(e, "/payments/gmpay/v1/order/create-transaction", body)
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d: %s", rec.Code, rec.Body.String())
}
}
// TestCreateOrderGmpayV1SolNative tests creating an order for native SOL token.
func TestCreateOrderGmpayV1SolNative(t *testing.T) {
e := setupTestEnv(t)
@@ -279,7 +297,7 @@ func TestCreateOrderGmpayV1SolNative(t *testing.T) {
"token": "sol",
"currency": "usd",
"network": "solana",
"notify_url": "http://localhost/notify",
"notify_url": "https://93.184.216.34/notify",
})
rec := doPost(e, "/payments/gmpay/v1/order/create-transaction", body)
@@ -334,7 +352,7 @@ func TestCreateOrderGmpayV1FormData(t *testing.T) {
"token": {"usdt"},
"currency": {"cny"},
"network": {"solana"},
"notify_url": {"http://localhost/notify"},
"notify_url": {"https://93.184.216.34/notify"},
})
rec := doFormPost(e, "/payments/gmpay/v1/order/create-transaction", values)
@@ -688,7 +706,7 @@ func seedOkPayNotifyFixture(t *testing.T) *okPayNotifyFixture {
Token: "USDT",
Network: mdb.NetworkTron,
Status: mdb.StatusWaitPay,
NotifyUrl: "http://localhost/notify",
NotifyUrl: "https://93.184.216.34/notify",
PaymentType: mdb.PaymentTypeEpay,
PayProvider: mdb.PaymentProviderOnChain,
}
@@ -928,7 +946,7 @@ func TestCreateOrderNetworkIsolation(t *testing.T) {
"token": "usdt",
"currency": "cny",
"network": "solana",
"notify_url": "http://localhost/notify",
"notify_url": "https://93.184.216.34/notify",
})
rec := doPost(e, "/payments/gmpay/v1/order/create-transaction", body)
@@ -956,7 +974,7 @@ func TestEpaySubmitPhpGetCompatible(t *testing.T) {
"type": {"alipay"},
"money": {"1.00"},
"out_trade_no": {"epay-get-001"},
"notify_url": {"http://localhost/notify"},
"notify_url": {"https://93.184.216.34/notify"},
"return_url": {"http://localhost/return"},
})
@@ -981,7 +999,7 @@ func TestEpaySubmitPhpPostFormCompatible(t *testing.T) {
"type": {"alipay"},
"money": {"1.00"},
"out_trade_no": {"epay-post-001"},
"notify_url": {"http://localhost/notify"},
"notify_url": {"https://93.184.216.34/notify"},
"return_url": {"http://localhost/return"},
"sitename": {"example-shop"},
})
@@ -1162,7 +1180,7 @@ func createCheckoutCounterRespTestOrder(t *testing.T, e *echo.Echo, orderID stri
"token": "usdt",
"currency": "cny",
"network": "tron",
"notify_url": "http://localhost/notify",
"notify_url": "https://93.184.216.34/notify",
"redirect_url": "https://merchant.example/return",
})
rec := doPost(e, "/payments/gmpay/v1/order/create-transaction", body)
@@ -1252,7 +1270,7 @@ func TestSwitchNetwork_WithOrder(t *testing.T) {
"token": "usdt",
"currency": "cny",
"network": "solana",
"notify_url": "http://localhost/notify",
"notify_url": "https://93.184.216.34/notify",
})
createRec := doPost(e, "/payments/gmpay/v1/order/create-transaction", createBody)
if createRec.Code != http.StatusOK {
@@ -1344,7 +1362,7 @@ func TestSwitchNetwork_OkPayCreatesProviderSubOrder(t *testing.T) {
"token": "usdt",
"currency": "cny",
"network": "solana",
"notify_url": "http://localhost/notify",
"notify_url": "https://93.184.216.34/notify",
})
createRec := doPost(e, "/payments/gmpay/v1/order/create-transaction", createBody)
if createRec.Code != http.StatusOK {
@@ -1441,7 +1459,7 @@ func TestSwitchNetwork_OkPayIntegration(t *testing.T) {
"token": "usdt",
"currency": "cny",
"network": "solana",
"notify_url": "http://localhost/notify",
"notify_url": "https://93.184.216.34/notify",
})
createRec := doPost(e, "/payments/gmpay/v1/order/create-transaction", createBody)
if createRec.Code != http.StatusOK {
+88
View File
@@ -0,0 +1,88 @@
package security
import (
"context"
"errors"
"fmt"
"net"
"net/url"
"strings"
"time"
)
const dnsLookupTimeout = 3 * time.Second
var lookupIPAddr = func(ctx context.Context, host string) ([]net.IPAddr, error) {
return net.DefaultResolver.LookupIPAddr(ctx, host)
}
// ValidatePublicHTTPURL verifies that raw is an HTTP(S) URL whose host does not
// point to localhost, private networks, link-local addresses, or metadata IPs.
func ValidatePublicHTTPURL(raw string) error {
raw = strings.TrimSpace(raw)
if raw == "" {
return errors.New("url is required")
}
parsed, err := url.Parse(raw)
if err != nil {
return fmt.Errorf("invalid url: %w", err)
}
scheme := strings.ToLower(parsed.Scheme)
if scheme != "http" && scheme != "https" {
return errors.New("url scheme must be http or https")
}
host := strings.TrimSpace(parsed.Hostname())
if host == "" {
return errors.New("url host is required")
}
if strings.Contains(host, "%") {
return fmt.Errorf("url host %q is not allowed", host)
}
if strings.TrimSuffix(strings.ToLower(host), ".") == "localhost" {
return fmt.Errorf("url host %q is not allowed", host)
}
if ip := net.ParseIP(host); ip != nil {
return validatePublicIP(host, ip)
}
ctx, cancel := context.WithTimeout(context.Background(), dnsLookupTimeout)
defer cancel()
addrs, err := lookupIPAddr(ctx, host)
if err != nil {
return fmt.Errorf("resolve url host %q: %w", host, err)
}
if len(addrs) == 0 {
return fmt.Errorf("resolve url host %q: no addresses", host)
}
for _, addr := range addrs {
if err := validatePublicIP(host, addr.IP); err != nil {
return err
}
}
return nil
}
func validatePublicIP(host string, ip net.IP) error {
if isUnsafeIP(ip) {
return fmt.Errorf("url host %q resolves to disallowed address %s", host, ip.String())
}
return nil
}
func isUnsafeIP(ip net.IP) bool {
if ip == nil {
return true
}
return ip.IsLoopback() ||
ip.IsPrivate() ||
ip.IsUnspecified() ||
ip.IsLinkLocalUnicast() ||
ip.IsLinkLocalMulticast() ||
ip.IsMulticast() ||
!ip.IsGlobalUnicast()
}
+77
View File
@@ -0,0 +1,77 @@
package security
import (
"context"
"errors"
"net"
"testing"
)
func withLookupIPAddr(t *testing.T, fn func(context.Context, string) ([]net.IPAddr, error)) {
t.Helper()
old := lookupIPAddr
lookupIPAddr = fn
t.Cleanup(func() {
lookupIPAddr = old
})
}
func TestValidatePublicHTTPURLAllowsPublicHTTPURL(t *testing.T) {
withLookupIPAddr(t, func(ctx context.Context, host string) ([]net.IPAddr, error) {
if host != "example.com" {
t.Fatalf("lookup host = %q, want example.com", host)
}
return []net.IPAddr{{IP: net.ParseIP("93.184.216.34")}}, nil
})
if err := ValidatePublicHTTPURL("https://example.com/api/"); err != nil {
t.Fatalf("ValidatePublicHTTPURL returned error: %v", err)
}
}
func TestValidatePublicHTTPURLRejectsUnsafeURLs(t *testing.T) {
tests := []string{
"",
"://bad",
"ftp://example.com/file",
"https:///missing-host",
"http://localhost/notify",
"http://localhost./notify",
"http://127.0.0.1/notify",
"http://[::1]/notify",
"http://10.0.0.1/notify",
"http://172.16.0.1/notify",
"http://172.31.255.255/notify",
"http://192.168.1.1/notify",
"http://169.254.1.1/notify",
"http://169.254.169.254/latest/meta-data/",
}
for _, raw := range tests {
t.Run(raw, func(t *testing.T) {
if err := ValidatePublicHTTPURL(raw); err == nil {
t.Fatalf("ValidatePublicHTTPURL(%q) returned nil, want error", raw)
}
})
}
}
func TestValidatePublicHTTPURLRejectsDomainResolvingToPrivateIP(t *testing.T) {
withLookupIPAddr(t, func(ctx context.Context, host string) ([]net.IPAddr, error) {
return []net.IPAddr{{IP: net.ParseIP("93.184.216.34")}, {IP: net.ParseIP("10.0.0.1")}}, nil
})
if err := ValidatePublicHTTPURL("https://example.com/api/"); err == nil {
t.Fatal("ValidatePublicHTTPURL returned nil, want error")
}
}
func TestValidatePublicHTTPURLRejectsUnresolvableDomain(t *testing.T) {
withLookupIPAddr(t, func(ctx context.Context, host string) ([]net.IPAddr, error) {
return nil, errors.New("lookup failed")
})
if err := ValidatePublicHTTPURL("https://example.com/api/"); err == nil {
t.Fatal("ValidatePublicHTTPURL returned nil, want error")
}
}