From 5503d146e6f05d9c6885a2fc4204c518e8a7dcaa Mon Sep 17 00:00:00 2001 From: sagitchu Date: Fri, 17 Apr 2026 11:45:47 +0800 Subject: [PATCH] fix(security): patch SSRF and config info disclosure vulnerabilities --- go-backend/internal/http/handler/handler.go | 15 +++++ go-backend/internal/http/handler/mutations.go | 25 +++++++- .../internal/http/handler/security_utils.go | 57 +++++++++++++++++++ 3 files changed, 96 insertions(+), 1 deletion(-) create mode 100644 go-backend/internal/http/handler/security_utils.go diff --git a/go-backend/internal/http/handler/handler.go b/go-backend/internal/http/handler/handler.go index 0aa93cd..97becfb 100644 --- a/go-backend/internal/http/handler/handler.go +++ b/go-backend/internal/http/handler/handler.go @@ -350,6 +350,12 @@ func (h *Handler) getConfigByName(w http.ResponseWriter, r *http.Request) { return } + switch req.Name { + 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 { response.WriteJSON(w, response.Err(-2, err.Error())) @@ -374,6 +380,15 @@ func (h *Handler) getConfigs(w http.ResponseWriter, r *http.Request) { response.WriteJSON(w, response.Err(-2, err.Error())) return } + + // Filter sensitive config keys if the user is not an admin + 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)) } diff --git a/go-backend/internal/http/handler/mutations.go b/go-backend/internal/http/handler/mutations.go index de0efec..ad1c980 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,15 @@ 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 +388,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"), @@ -1680,6 +1693,10 @@ func (h *Handler) forwardCreate(w http.ResponseWriter, r *http.Request) { response.WriteJSON(w, response.Err(-1, "普通用户无法设置限速规则")) return } + if err := IsSafeRemoteAddr(remoteAddr); err != nil { + response.WriteJSON(w, response.Err(403, "禁止将目标地址设置为内部网络或保留地址")) + return + } } speedID := asAnyToInt64Ptr(req["speedId"]) speedID, err = h.normalizeSpeedLimitReference(speedID) @@ -1795,6 +1812,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..cc11c5f --- /dev/null +++ b/go-backend/internal/http/handler/security_utils.go @@ -0,0 +1,57 @@ +package handler + +import ( + "fmt" + "net" + "strings" +) + +// 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 { + host, _, err := net.SplitHostPort(addr) + if err != nil { + // If there is no port, try to treat the whole string as host + 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") + } + + // Try parsing as host:port + _, _, err := net.SplitHostPort(addr) + if err != nil { + if strings.Contains(err.Error(), "missing port in address") { + // It's just a host, which is fine + } else { + return fmt.Errorf("invalid address format") + } + } + return nil +}