fix(security): SSRF-guard node and remote HTTP clients

The Node.Probe and Remote.do paths built outbound URLs by string-
formatting admin-controlled fields (Scheme/Address/Port/BasePath)
straight into requests, then dialed the result with the default
transport. CodeQL flagged this as go/request-forgery — an admin
(or anyone who compromises the admin account) could point a node
at internal infrastructure (cloud metadata, RFC1918 ranges, etc.)
and the panel would dutifully fetch it.

Add util/netsafe with a shared TOCTOU-safe DialContext that
resolves the host, rejects private/internal IPs unless the
per-request context whitelists them (per-node AllowPrivateAddress
flag, plumbed through context.Value), and dials the resolved IP
directly so the IP that passed the check is the IP we connect to.
This closes the DNS-rebinding window where a hostname could
resolve to a public IP at check time and a private one at dial.

Also tighten address validation (NormalizeHost rejects anything
that isn't a bare hostname or IP literal — no embedded paths,
userinfo, schemes) and switch URL construction from fmt.Sprintf to
url.URL{} + net.JoinHostPort so admin-supplied values can't smuggle
URL components.

custom_geo.go's isBlockedIP now delegates to netsafe so there's
one source of truth.
This commit is contained in:
MHSanaei
2026-05-13 13:33:53 +02:00
parent 9fc47b3d41
commit 38da210ded
4 changed files with 163 additions and 15 deletions
+30 -7
View File
@@ -7,6 +7,7 @@ import (
"errors"
"fmt"
"io"
"net"
"net/http"
"net/url"
"strconv"
@@ -16,6 +17,7 @@ import (
"github.com/mhsanaei/3x-ui/v3/database/model"
"github.com/mhsanaei/3x-ui/v3/logger"
"github.com/mhsanaei/3x-ui/v3/util/netsafe"
)
const remoteHTTPTimeout = 10 * time.Second
@@ -25,6 +27,7 @@ var remoteHTTPClient = &http.Client{
MaxIdleConns: 64,
MaxIdleConnsPerHost: 4,
IdleConnTimeout: 60 * time.Second,
DialContext: netsafe.SSRFGuardedDialContext,
},
}
@@ -50,7 +53,18 @@ func NewRemote(n *model.Node) *Remote {
func (r *Remote) Name() string { return "node:" + r.node.Name }
func (r *Remote) baseURL() string {
func (r *Remote) baseURL() (string, error) {
addr, err := netsafe.NormalizeHost(r.node.Address)
if err != nil {
return "", err
}
scheme := r.node.Scheme
if scheme != "http" && scheme != "https" {
scheme = "https"
}
if r.node.Port <= 0 || r.node.Port > 65535 {
return "", fmt.Errorf("invalid node port %d", r.node.Port)
}
bp := r.node.BasePath
if bp == "" {
bp = "/"
@@ -58,7 +72,12 @@ func (r *Remote) baseURL() string {
if !strings.HasSuffix(bp, "/") {
bp += "/"
}
return fmt.Sprintf("%s://%s:%d%s", r.node.Scheme, r.node.Address, r.node.Port, bp)
u := &url.URL{
Scheme: scheme,
Host: net.JoinHostPort(addr, strconv.Itoa(r.node.Port)),
Path: bp,
}
return u.String(), nil
}
func (r *Remote) do(ctx context.Context, method, path string, body any) (*envelope, error) {
@@ -66,7 +85,11 @@ func (r *Remote) do(ctx context.Context, method, path string, body any) (*envelo
return nil, errors.New("node has no API token configured")
}
target := r.baseURL() + strings.TrimPrefix(path, "/")
base, err := r.baseURL()
if err != nil {
return nil, err
}
target := base + strings.TrimPrefix(path, "/")
var (
reqBody io.Reader
@@ -78,15 +101,15 @@ func (r *Remote) do(ctx context.Context, method, path string, body any) (*envelo
reqBody = strings.NewReader(b.Encode())
contentType = "application/x-www-form-urlencoded"
default:
buf, err := json.Marshal(b)
if err != nil {
return nil, fmt.Errorf("marshal body: %w", err)
buf, jerr := json.Marshal(b)
if jerr != nil {
return nil, fmt.Errorf("marshal body: %w", jerr)
}
reqBody = bytes.NewReader(buf)
contentType = "application/json"
}
cctx, cancel := context.WithTimeout(ctx, remoteHTTPTimeout)
cctx, cancel := context.WithTimeout(netsafe.ContextWithAllowPrivate(ctx, r.node.AllowPrivateAddress), remoteHTTPTimeout)
defer cancel()
req, err := http.NewRequestWithContext(cctx, method, target, reqBody)
if err != nil {
+2 -2
View File
@@ -18,6 +18,7 @@ import (
"github.com/mhsanaei/3x-ui/v3/database"
"github.com/mhsanaei/3x-ui/v3/database/model"
"github.com/mhsanaei/3x-ui/v3/logger"
"github.com/mhsanaei/3x-ui/v3/util/netsafe"
)
const (
@@ -164,8 +165,7 @@ func CustomGeoLocalFileNeedsRepair(path string) bool {
}
func isBlockedIP(ip net.IP) bool {
return ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() ||
ip.IsLinkLocalMulticast() || ip.IsUnspecified()
return netsafe.IsBlockedIP(ip)
}
// checkSSRFDefault validates that the given host does not resolve to a private/internal IP.
+30 -6
View File
@@ -5,7 +5,9 @@ import (
"encoding/json"
"errors"
"fmt"
"net"
"net/http"
"net/url"
"strconv"
"strings"
"time"
@@ -13,6 +15,7 @@ import (
"github.com/mhsanaei/3x-ui/v3/database"
"github.com/mhsanaei/3x-ui/v3/database/model"
"github.com/mhsanaei/3x-ui/v3/util/common"
"github.com/mhsanaei/3x-ui/v3/util/netsafe"
"github.com/mhsanaei/3x-ui/v3/web/runtime"
)
@@ -34,6 +37,7 @@ var nodeHTTPClient = &http.Client{
MaxIdleConns: 64,
MaxIdleConnsPerHost: 4,
IdleConnTimeout: 60 * time.Second,
DialContext: netsafe.SSRFGuardedDialContext,
},
}
@@ -69,14 +73,15 @@ func normalizeBasePath(p string) string {
func (s *NodeService) normalize(n *model.Node) error {
n.Name = strings.TrimSpace(n.Name)
n.Address = strings.TrimSpace(n.Address)
n.ApiToken = strings.TrimSpace(n.ApiToken)
if n.Name == "" {
return common.NewError("node name is required")
}
if n.Address == "" {
return common.NewError("node address is required")
addr, err := netsafe.NormalizeHost(n.Address)
if err != nil {
return common.NewError(err.Error())
}
n.Address = addr
if n.Port <= 0 || n.Port > 65535 {
return common.NewError("node port must be 1-65535")
}
@@ -175,10 +180,29 @@ func (s *NodeService) AggregateNodeMetric(id int, metric string, bucketSeconds i
func (s *NodeService) Probe(ctx context.Context, n *model.Node) (HeartbeatPatch, error) {
patch := HeartbeatPatch{LastHeartbeat: time.Now().Unix()}
url := fmt.Sprintf("%s://%s:%d%spanel/api/server/status",
n.Scheme, n.Address, n.Port, normalizeBasePath(n.BasePath))
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
addr, err := netsafe.NormalizeHost(n.Address)
if err != nil {
patch.LastError = err.Error()
return patch, err
}
scheme := n.Scheme
if scheme != "http" && scheme != "https" {
scheme = "https"
}
if n.Port <= 0 || n.Port > 65535 {
patch.LastError = "node port must be 1-65535"
return patch, errors.New(patch.LastError)
}
probeURL := &url.URL{
Scheme: scheme,
Host: net.JoinHostPort(addr, strconv.Itoa(n.Port)),
Path: normalizeBasePath(n.BasePath) + "panel/api/server/status",
}
req, err := http.NewRequestWithContext(
netsafe.ContextWithAllowPrivate(ctx, n.AllowPrivateAddress),
http.MethodGet, probeURL.String(), nil)
if err != nil {
patch.LastError = err.Error()
return patch, err