mirror of
https://github.com/GMWalletApp/epusdt.git
synced 2026-07-07 10:16:15 +00:00
fix: reject private callback and rate API URLs
This commit is contained in:
@@ -10,6 +10,7 @@ import (
|
|||||||
"github.com/GMWalletApp/epusdt/model/data"
|
"github.com/GMWalletApp/epusdt/model/data"
|
||||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||||
"github.com/GMWalletApp/epusdt/telegram"
|
"github.com/GMWalletApp/epusdt/telegram"
|
||||||
|
"github.com/GMWalletApp/epusdt/util/security"
|
||||||
"github.com/labstack/echo/v4"
|
"github.com/labstack/echo/v4"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -204,6 +205,13 @@ func normalizeAndValidateSettingItem(group, key, value string) (string, error) {
|
|||||||
return value, err
|
return value, err
|
||||||
}
|
}
|
||||||
return normalized, nil
|
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
|
return value, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import (
|
|||||||
"github.com/GMWalletApp/epusdt/util/constant"
|
"github.com/GMWalletApp/epusdt/util/constant"
|
||||||
"github.com/GMWalletApp/epusdt/util/log"
|
"github.com/GMWalletApp/epusdt/util/log"
|
||||||
"github.com/GMWalletApp/epusdt/util/math"
|
"github.com/GMWalletApp/epusdt/util/math"
|
||||||
|
"github.com/GMWalletApp/epusdt/util/security"
|
||||||
"github.com/dromara/carbon/v2"
|
"github.com/dromara/carbon/v2"
|
||||||
"github.com/shopspring/decimal"
|
"github.com/shopspring/decimal"
|
||||||
)
|
)
|
||||||
@@ -54,12 +55,17 @@ func normalizeOrderAddressByNetwork(network, address string) string {
|
|||||||
|
|
||||||
// CreateTransaction creates a new payment order.
|
// CreateTransaction creates a new payment order.
|
||||||
func CreateTransaction(req *request.CreateTransactionRequest, apiKey *mdb.ApiKey) (*response.CreateTransactionResponse, error) {
|
func CreateTransaction(req *request.CreateTransactionRequest, apiKey *mdb.ApiKey) (*response.CreateTransactionResponse, error) {
|
||||||
gCreateTransactionLock.Lock()
|
|
||||||
defer gCreateTransactionLock.Unlock()
|
|
||||||
|
|
||||||
token := strings.ToUpper(strings.TrimSpace(req.Token))
|
token := strings.ToUpper(strings.TrimSpace(req.Token))
|
||||||
currency := strings.ToUpper(strings.TrimSpace(req.Currency))
|
currency := strings.ToUpper(strings.TrimSpace(req.Currency))
|
||||||
network := strings.ToLower(strings.TrimSpace(req.Network))
|
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()
|
amountPrecision := data.GetAmountPrecision()
|
||||||
payAmount := math.MustParsePrecFloat64(req.Amount, amountPrecision)
|
payAmount := math.MustParsePrecFloat64(req.Amount, amountPrecision)
|
||||||
rate := config.GetRateForCoin(strings.ToLower(token), strings.ToLower(currency))
|
rate := config.GetRateForCoin(strings.ToLower(token), strings.ToLower(currency))
|
||||||
@@ -116,7 +122,7 @@ func CreateTransaction(req *request.CreateTransactionRequest, apiKey *mdb.ApiKey
|
|||||||
Token: token,
|
Token: token,
|
||||||
Network: network,
|
Network: network,
|
||||||
Status: mdb.StatusWaitPay,
|
Status: mdb.StatusWaitPay,
|
||||||
NotifyUrl: req.NotifyUrl,
|
NotifyUrl: notifyURL,
|
||||||
RedirectUrl: req.RedirectUrl,
|
RedirectUrl: req.RedirectUrl,
|
||||||
Name: req.Name,
|
Name: req.Name,
|
||||||
PaymentType: req.PaymentType,
|
PaymentType: req.PaymentType,
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ func newCreateTransactionRequest(orderID string, amount float64) *request.Create
|
|||||||
Token: "USDT",
|
Token: "USDT",
|
||||||
Network: "tron",
|
Network: "tron",
|
||||||
Amount: amount,
|
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) {
|
func TestCreateTransactionAssignsIncrementedAmountsAndLocks(t *testing.T) {
|
||||||
cleanup := testutil.SetupTestDatabases(t)
|
cleanup := testutil.SetupTestDatabases(t)
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
|
|||||||
@@ -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) {
|
func TestAdminSettings_DeleteThenReupsertRestoresSetting(t *testing.T) {
|
||||||
e, token := setupAdminTestEnv(t)
|
e, token := setupAdminTestEnv(t)
|
||||||
|
|
||||||
rec := doPutAdmin(e, "/admin/api/v1/settings", map[string]interface{}{
|
rec := doPutAdmin(e, "/admin/api/v1/settings", map[string]interface{}{
|
||||||
"items": []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)
|
}, token)
|
||||||
assertOK(t, rec)
|
assertOK(t, rec)
|
||||||
@@ -1053,7 +1094,7 @@ func TestAdminSettings_DeleteThenReupsertRestoresSetting(t *testing.T) {
|
|||||||
|
|
||||||
rec = doPutAdmin(e, "/admin/api/v1/settings", map[string]interface{}{
|
rec = doPutAdmin(e, "/admin/api/v1/settings", map[string]interface{}{
|
||||||
"items": []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)
|
}, token)
|
||||||
assertOK(t, rec)
|
assertOK(t, rec)
|
||||||
@@ -1069,7 +1110,7 @@ func TestAdminSettings_DeleteThenReupsertRestoresSetting(t *testing.T) {
|
|||||||
item, _ := row.(map[string]interface{})
|
item, _ := row.(map[string]interface{})
|
||||||
if item["key"] == mdb.SettingKeyRateApiUrl {
|
if item["key"] == mdb.SettingKeyRateApiUrl {
|
||||||
found = true
|
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"])
|
t.Fatalf("rate.api_url value = %v, want new value", item["value"])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1350,7 +1391,7 @@ func TestAdminOrders_ListWithSubExcludesSubOrdersFromTopLevel(t *testing.T) {
|
|||||||
"currency": "CNY",
|
"currency": "CNY",
|
||||||
"token": "USDT",
|
"token": "USDT",
|
||||||
"network": "tron",
|
"network": "tron",
|
||||||
"notify_url": "https://merchant.example/callback",
|
"notify_url": "https://93.184.216.34/callback",
|
||||||
"redirect_url": "https://merchant.example/redirect",
|
"redirect_url": "https://merchant.example/redirect",
|
||||||
})
|
})
|
||||||
rec := doPost(e, "/payments/gmpay/v1/order/create-transaction", parentBody)
|
rec := doPost(e, "/payments/gmpay/v1/order/create-transaction", parentBody)
|
||||||
|
|||||||
+29
-11
@@ -240,7 +240,7 @@ func TestCreateOrderGmpayV1Solana(t *testing.T) {
|
|||||||
"token": "usdt",
|
"token": "usdt",
|
||||||
"currency": "cny",
|
"currency": "cny",
|
||||||
"network": "solana",
|
"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)
|
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"])
|
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.
|
// TestCreateOrderGmpayV1SolNative tests creating an order for native SOL token.
|
||||||
func TestCreateOrderGmpayV1SolNative(t *testing.T) {
|
func TestCreateOrderGmpayV1SolNative(t *testing.T) {
|
||||||
e := setupTestEnv(t)
|
e := setupTestEnv(t)
|
||||||
@@ -279,7 +297,7 @@ func TestCreateOrderGmpayV1SolNative(t *testing.T) {
|
|||||||
"token": "sol",
|
"token": "sol",
|
||||||
"currency": "usd",
|
"currency": "usd",
|
||||||
"network": "solana",
|
"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)
|
rec := doPost(e, "/payments/gmpay/v1/order/create-transaction", body)
|
||||||
@@ -334,7 +352,7 @@ func TestCreateOrderGmpayV1FormData(t *testing.T) {
|
|||||||
"token": {"usdt"},
|
"token": {"usdt"},
|
||||||
"currency": {"cny"},
|
"currency": {"cny"},
|
||||||
"network": {"solana"},
|
"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)
|
rec := doFormPost(e, "/payments/gmpay/v1/order/create-transaction", values)
|
||||||
@@ -688,7 +706,7 @@ func seedOkPayNotifyFixture(t *testing.T) *okPayNotifyFixture {
|
|||||||
Token: "USDT",
|
Token: "USDT",
|
||||||
Network: mdb.NetworkTron,
|
Network: mdb.NetworkTron,
|
||||||
Status: mdb.StatusWaitPay,
|
Status: mdb.StatusWaitPay,
|
||||||
NotifyUrl: "http://localhost/notify",
|
NotifyUrl: "https://93.184.216.34/notify",
|
||||||
PaymentType: mdb.PaymentTypeEpay,
|
PaymentType: mdb.PaymentTypeEpay,
|
||||||
PayProvider: mdb.PaymentProviderOnChain,
|
PayProvider: mdb.PaymentProviderOnChain,
|
||||||
}
|
}
|
||||||
@@ -928,7 +946,7 @@ func TestCreateOrderNetworkIsolation(t *testing.T) {
|
|||||||
"token": "usdt",
|
"token": "usdt",
|
||||||
"currency": "cny",
|
"currency": "cny",
|
||||||
"network": "solana",
|
"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)
|
rec := doPost(e, "/payments/gmpay/v1/order/create-transaction", body)
|
||||||
|
|
||||||
@@ -956,7 +974,7 @@ func TestEpaySubmitPhpGetCompatible(t *testing.T) {
|
|||||||
"type": {"alipay"},
|
"type": {"alipay"},
|
||||||
"money": {"1.00"},
|
"money": {"1.00"},
|
||||||
"out_trade_no": {"epay-get-001"},
|
"out_trade_no": {"epay-get-001"},
|
||||||
"notify_url": {"http://localhost/notify"},
|
"notify_url": {"https://93.184.216.34/notify"},
|
||||||
"return_url": {"http://localhost/return"},
|
"return_url": {"http://localhost/return"},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -981,7 +999,7 @@ func TestEpaySubmitPhpPostFormCompatible(t *testing.T) {
|
|||||||
"type": {"alipay"},
|
"type": {"alipay"},
|
||||||
"money": {"1.00"},
|
"money": {"1.00"},
|
||||||
"out_trade_no": {"epay-post-001"},
|
"out_trade_no": {"epay-post-001"},
|
||||||
"notify_url": {"http://localhost/notify"},
|
"notify_url": {"https://93.184.216.34/notify"},
|
||||||
"return_url": {"http://localhost/return"},
|
"return_url": {"http://localhost/return"},
|
||||||
"sitename": {"example-shop"},
|
"sitename": {"example-shop"},
|
||||||
})
|
})
|
||||||
@@ -1162,7 +1180,7 @@ func createCheckoutCounterRespTestOrder(t *testing.T, e *echo.Echo, orderID stri
|
|||||||
"token": "usdt",
|
"token": "usdt",
|
||||||
"currency": "cny",
|
"currency": "cny",
|
||||||
"network": "tron",
|
"network": "tron",
|
||||||
"notify_url": "http://localhost/notify",
|
"notify_url": "https://93.184.216.34/notify",
|
||||||
"redirect_url": "https://merchant.example/return",
|
"redirect_url": "https://merchant.example/return",
|
||||||
})
|
})
|
||||||
rec := doPost(e, "/payments/gmpay/v1/order/create-transaction", body)
|
rec := doPost(e, "/payments/gmpay/v1/order/create-transaction", body)
|
||||||
@@ -1252,7 +1270,7 @@ func TestSwitchNetwork_WithOrder(t *testing.T) {
|
|||||||
"token": "usdt",
|
"token": "usdt",
|
||||||
"currency": "cny",
|
"currency": "cny",
|
||||||
"network": "solana",
|
"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)
|
createRec := doPost(e, "/payments/gmpay/v1/order/create-transaction", createBody)
|
||||||
if createRec.Code != http.StatusOK {
|
if createRec.Code != http.StatusOK {
|
||||||
@@ -1344,7 +1362,7 @@ func TestSwitchNetwork_OkPayCreatesProviderSubOrder(t *testing.T) {
|
|||||||
"token": "usdt",
|
"token": "usdt",
|
||||||
"currency": "cny",
|
"currency": "cny",
|
||||||
"network": "solana",
|
"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)
|
createRec := doPost(e, "/payments/gmpay/v1/order/create-transaction", createBody)
|
||||||
if createRec.Code != http.StatusOK {
|
if createRec.Code != http.StatusOK {
|
||||||
@@ -1441,7 +1459,7 @@ func TestSwitchNetwork_OkPayIntegration(t *testing.T) {
|
|||||||
"token": "usdt",
|
"token": "usdt",
|
||||||
"currency": "cny",
|
"currency": "cny",
|
||||||
"network": "solana",
|
"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)
|
createRec := doPost(e, "/payments/gmpay/v1/order/create-transaction", createBody)
|
||||||
if createRec.Code != http.StatusOK {
|
if createRec.Code != http.StatusOK {
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user