From 8611748c4685c66e6c83529252cbbe897dd50081 Mon Sep 17 00:00:00 2001 From: sagitchu Date: Mon, 20 Apr 2026 11:31:17 +0800 Subject: [PATCH] fix(security): patch SSRF and info disclosure vulnerabilities --- .../internal/http/handler/federation.go | 10 ++++ go-backend/internal/http/handler/handler.go | 15 ++++- go-backend/internal/http/handler/mutations.go | 23 ++++++- .../internal/http/handler/security_utils.go | 60 +++++++++++++++++++ go-backend/internal/http/middleware/auth.go | 2 +- .../tests/contract/db_test_helper_test.go | 5 ++ 6 files changed, 112 insertions(+), 3 deletions(-) create mode 100644 go-backend/internal/http/handler/security_utils.go diff --git a/go-backend/internal/http/handler/federation.go b/go-backend/internal/http/handler/federation.go index 62fd3ec..429a640 100644 --- a/go-backend/internal/http/handler/federation.go +++ b/go-backend/internal/http/handler/federation.go @@ -5,6 +5,7 @@ import ( "fmt" "net" "net/http" + "net/url" "sort" "strconv" "strings" @@ -635,6 +636,15 @@ func (h *Handler) nodeImport(w http.ResponseWriter, r *http.Request) { response.WriteJSON(w, response.ErrDefault("Remote URL and Token are required")) return } + rURL, err := url.Parse(req.RemoteURL) + if err != nil || (rURL.Scheme != "http" && rURL.Scheme != "https") { + response.WriteJSON(w, response.ErrDefault("Invalid Remote URL format")) + return + } + if err := IsSafeRemoteAddr(rURL.Host); err != nil { + response.WriteJSON(w, response.Err(403, "禁止将远程节点地址设置为内部网络或保留地址")) + return + } domainCfg, _ := h.repo.GetConfigByName("panel_domain") localDomain := "" diff --git a/go-backend/internal/http/handler/handler.go b/go-backend/internal/http/handler/handler.go index 815bd58..c9262d7 100644 --- a/go-backend/internal/http/handler/handler.go +++ b/go-backend/internal/http/handler/handler.go @@ -27,6 +27,7 @@ import ( "github.com/google/uuid" ) + type Handler struct { repo *repo.Repository jwtSecret string @@ -356,6 +357,12 @@ func (h *Handler) getConfigByName(w http.ResponseWriter, r *http.Request) { response.WriteJSON(w, response.ErrDefault("配置名称不能为空")) return } + configName := strings.ToLower(strings.TrimSpace(req.Name)) + switch configName { + case "license_key", "cloudflare_secret_key", "jwt_secret": + response.WriteJSON(w, response.Err(403, "禁止访问敏感配置")) + return + } cfg, err := h.repo.GetConfigByName(req.Name) if err != nil { @@ -381,6 +388,12 @@ func (h *Handler) getConfigs(w http.ResponseWriter, r *http.Request) { response.WriteJSON(w, response.Err(-2, err.Error())) return } + ctxClaims := r.Context().Value(middleware.ClaimsContextKey) + if claims, ok := ctxClaims.(auth.Claims); !ok || claims.RoleID != 0 { + delete(cfgMap, "license_key") + delete(cfgMap, "cloudflare_secret_key") + delete(cfgMap, "jwt_secret") + } response.WriteJSON(w, response.OK(cfgMap)) } @@ -852,7 +865,7 @@ func (h *Handler) licenseActivate(w http.ResponseWriter, r *http.Request) { response.WriteJSON(w, response.ErrDefault("设备绑定失败: "+err.Error())) return } - + // Validation might still fail with scope if we don't query via machine id, but since activate machine succeeded // we can consider the license valid for our simple usecase } else { diff --git a/go-backend/internal/http/handler/mutations.go b/go-backend/internal/http/handler/mutations.go index 9db35e5..a4dba82 100644 --- a/go-backend/internal/http/handler/mutations.go +++ b/go-backend/internal/http/handler/mutations.go @@ -301,6 +301,10 @@ func (h *Handler) nodeCreate(w http.ResponseWriter, r *http.Request) { response.WriteJSON(w, response.ErrDefault("节点名称和地址不能为空")) return } + if err := IsValidNodeAddress(serverIP); err != nil { + response.WriteJSON(w, response.ErrDefault(err.Error())) + return + } now := time.Now().UnixMilli() inx := h.repo.NextIndex("node") @@ -365,6 +369,13 @@ func (h *Handler) nodeUpdate(w http.ResponseWriter, r *http.Request) { newHTTP := asInt(req["http"], currentHTTP) newTLS := asInt(req["tls"], currentTLS) newSocks := asInt(req["socks"], currentSocks) + serverIP := asString(req["serverIp"]) + if serverIP != "" { + if err := IsValidNodeAddress(serverIP); err != nil { + response.WriteJSON(w, response.ErrDefault(err.Error())) + return + } + } if currentStatus == 1 && (newHTTP != currentHTTP || newTLS != currentTLS || newSocks != currentSocks) { if err := h.applyNodeProtocolChange(id, newHTTP, newTLS, newSocks); err != nil { response.WriteJSON(w, response.ErrDefault(err.Error())) @@ -375,7 +386,7 @@ func (h *Handler) nodeUpdate(w http.ResponseWriter, r *http.Request) { now := time.Now().UnixMilli() if err := h.repo.UpdateNode(id, asString(req["name"]), - asString(req["serverIp"]), + serverIP, nullableText(asString(req["serverIpV4"])), nullableText(asString(req["serverIpV6"])), defaultString(asString(req["port"]), "1000-65535"), @@ -1686,6 +1697,10 @@ func (h *Handler) forwardCreate(w http.ResponseWriter, r *http.Request) { return } if roleID != 0 { + if err := IsSafeRemoteAddr(remoteAddr); err != nil { + response.WriteJSON(w, response.Err(403, "禁止将目标地址设置为内部网络或保留地址")) + return + } if speedIDVal, ok := req["speedId"]; ok && speedIDVal != nil { response.WriteJSON(w, response.Err(-1, "普通用户无法设置限速规则")) return @@ -1805,6 +1820,12 @@ func (h *Handler) forwardUpdate(w http.ResponseWriter, r *http.Request) { if remoteAddr == "" { remoteAddr = forward.RemoteAddr } + if actorRole != 0 { + if err := IsSafeRemoteAddr(remoteAddr); err != nil { + response.WriteJSON(w, response.Err(403, "禁止将目标地址设置为内部网络或保留地址")) + return + } + } strategy := strings.TrimSpace(asString(req["strategy"])) if strategy == "" { strategy = forward.Strategy diff --git a/go-backend/internal/http/handler/security_utils.go b/go-backend/internal/http/handler/security_utils.go new file mode 100644 index 0000000..d121389 --- /dev/null +++ b/go-backend/internal/http/handler/security_utils.go @@ -0,0 +1,60 @@ +package handler + +import ( + "fmt" + "net" + "strings" +) + +// DisableSafeRemoteAddrCheckForTesting allows bypassing the safety check during integration tests. +var DisableSafeRemoteAddrCheckForTesting = false + +// IsSafeRemoteAddr checks if a given address is safe to connect to (prevents SSRF/Open Proxy). +// It resolves domains to IPs to prevent DNS rebinding attacks pointing to internal networks. +func IsSafeRemoteAddr(addr string) error { + if DisableSafeRemoteAddrCheckForTesting { + return nil + } + + host, _, err := net.SplitHostPort(addr) + if err != nil { + if strings.Contains(err.Error(), "missing port in address") { + host = addr + } else { + return fmt.Errorf("invalid address format: %v", err) + } + } + + ips, err := net.LookupIP(host) + if err != nil { + return fmt.Errorf("could not resolve address: %v", err) + } + + for _, ip := range ips { + if ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || ip.IsUnspecified() || ip.IsMulticast() { + return fmt.Errorf("address resolves to internal or reserved IP: %s", ip.String()) + } + } + + return nil +} + +// IsValidNodeAddress ensures the address is strictly a host or host:port. +// It explicitly denies schemes (http://, https://), paths (/...), and query params (?). +func IsValidNodeAddress(addr string) error { + addr = strings.TrimSpace(addr) + if strings.Contains(addr, "://") { + return fmt.Errorf("address must not contain scheme (e.g. http://)") + } + if strings.ContainsAny(addr, "/?") { + return fmt.Errorf("address must not contain path or query parameters") + } + + _, _, err := net.SplitHostPort(addr) + if err != nil { + if !strings.Contains(err.Error(), "missing port in address") { + return fmt.Errorf("invalid address format") + } + } + return nil +} diff --git a/go-backend/internal/http/middleware/auth.go b/go-backend/internal/http/middleware/auth.go index 68ac48b..e739ac7 100644 --- a/go-backend/internal/http/middleware/auth.go +++ b/go-backend/internal/http/middleware/auth.go @@ -109,7 +109,7 @@ func requiresAdmin(path string) bool { return true } - if strings.HasPrefix(path, "/api/v1/federation/share/") { + if strings.HasPrefix(path, "/api/v1/federation/share/") || strings.HasPrefix(path, "/api/v1/federation/node/") { return true } diff --git a/go-backend/tests/contract/db_test_helper_test.go b/go-backend/tests/contract/db_test_helper_test.go index 322ade9..b41ba20 100644 --- a/go-backend/tests/contract/db_test_helper_test.go +++ b/go-backend/tests/contract/db_test_helper_test.go @@ -6,9 +6,14 @@ import ( "strings" "testing" + "go-backend/internal/http/handler" "go-backend/internal/store/repo" ) +func init() { + handler.DisableSafeRemoteAddrCheckForTesting = true +} + func mustLastInsertID(t *testing.T, r *repo.Repository, label string) int64 { t.Helper() var id int64