diff --git a/src/controller/admin/settings_controller.go b/src/controller/admin/settings_controller.go index 011667d..20e8d3d 100644 --- a/src/controller/admin/settings_controller.go +++ b/src/controller/admin/settings_controller.go @@ -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 } diff --git a/src/model/service/order_service.go b/src/model/service/order_service.go index a85866f..30387a0 100644 --- a/src/model/service/order_service.go +++ b/src/model/service/order_service.go @@ -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, diff --git a/src/model/service/order_service_test.go b/src/model/service/order_service_test.go index 3c4cfa0..5ced0d0 100644 --- a/src/model/service/order_service_test.go +++ b/src/model/service/order_service_test.go @@ -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() diff --git a/src/route/admin_router_test.go b/src/route/admin_router_test.go index dd73603..f099ac2 100644 --- a/src/route/admin_router_test.go +++ b/src/route/admin_router_test.go @@ -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) diff --git a/src/route/router_test.go b/src/route/router_test.go index 687f62b..617488b 100644 --- a/src/route/router_test.go +++ b/src/route/router_test.go @@ -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 { diff --git a/src/util/security/url_guard.go b/src/util/security/url_guard.go new file mode 100644 index 0000000..3fad0a4 --- /dev/null +++ b/src/util/security/url_guard.go @@ -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() +} diff --git a/src/util/security/url_guard_test.go b/src/util/security/url_guard_test.go new file mode 100644 index 0000000..6d83ca5 --- /dev/null +++ b/src/util/security/url_guard_test.go @@ -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") + } +}