fix(server): apply the outbound address policy to remote cert pinning

The remote certificate fetch now dials through the same netsafe guard
as the REALITY target scan. A private or loopback endpoint is refused
unless the request carries allowPrivate; the inbound form asks the
operator to confirm and retries with the opt-in.
This commit is contained in:
MHSanaei
2026-10-03 13:20:00 +02:00
parent d31465e37b
commit ede275e4dc
8 changed files with 87 additions and 8 deletions
+8 -1
View File
@@ -1,6 +1,7 @@
package controller
import (
"errors"
"fmt"
"net/http"
"regexp"
@@ -10,6 +11,7 @@ import (
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/logger"
"github.com/mhsanaei/3x-ui/v3/internal/util/netsafe"
"github.com/mhsanaei/3x-ui/v3/internal/web/entity"
"github.com/mhsanaei/3x-ui/v3/internal/web/global"
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
@@ -467,7 +469,12 @@ func (a *ServerController) getCertHash(c *gin.Context) {
// getRemoteCertHash runs `xray tls ping` against the given server and returns
// its live certificate SHA-256 hash(es) for pinning.
func (a *ServerController) getRemoteCertHash(c *gin.Context) {
hashes, err := a.serverService.GetRemoteCertHash(c.PostForm("server"))
allowPrivate := c.PostForm("allowPrivate") == "true"
hashes, err := a.serverService.GetRemoteCertHash(c.PostForm("server"), allowPrivate)
if errors.Is(err, netsafe.ErrPrivateAddressBlocked) {
jsonMsgObj(c, "get remote cert hash", gin.H{"privateTarget": true}, err)
return
}
if err != nil {
jsonMsg(c, "get remote cert hash", err)
return
+7 -4
View File
@@ -39,6 +39,7 @@ import (
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/logger"
"github.com/mhsanaei/3x-ui/v3/internal/util/common"
"github.com/mhsanaei/3x-ui/v3/internal/util/netsafe"
"github.com/mhsanaei/3x-ui/v3/internal/util/sys"
"github.com/mhsanaei/3x-ui/v3/internal/xray"
@@ -2757,7 +2758,8 @@ func walkCertFiles(node any, out []string) []string {
// proxy). A native handshake replaces the old `xray tls ping` subprocess so the
// real dial/handshake failure (connection refused, timeout, …) surfaces
// verbatim. `server` may be host or host:port; the port defaults to 443.
func (s *ServerService) GetRemoteCertHash(server string) ([]string, error) {
// allowPrivate lifts the SSRF guard for this one probe (the panel's confirmed opt-in).
func (s *ServerService) GetRemoteCertHash(server string, allowPrivate bool) ([]string, error) {
server = strings.TrimSpace(server)
if server == "" {
return nil, common.NewError("no server provided")
@@ -2768,10 +2770,11 @@ func (s *ServerService) GetRemoteCertHash(server string) ([]string, error) {
host, port = h, p
}
dialer := stdnet.Dialer{Timeout: 10 * time.Second}
tcpConn, err := dialer.Dial("tcp", stdnet.JoinHostPort(host, port))
ctx, cancel := context.WithTimeout(netsafe.ContextWithAllowPrivate(context.Background(), allowPrivate), 10*time.Second)
defer cancel()
tcpConn, err := netsafe.SSRFGuardedDialContext(ctx, "tcp", stdnet.JoinHostPort(host, port))
if err != nil {
return nil, common.NewErrorf("failed to dial %s: %s", stdnet.JoinHostPort(host, port), err)
return nil, fmt.Errorf("failed to dial %s: %w", stdnet.JoinHostPort(host, port), err)
}
defer tcpConn.Close()
_ = tcpConn.SetDeadline(time.Now().Add(15 * time.Second))
@@ -0,0 +1,38 @@
package service
import (
"crypto/sha256"
"encoding/hex"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/util/netsafe"
)
func TestGetRemoteCertHashGuardsPrivateTargets(t *testing.T) {
srv := httptest.NewTLSServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
defer srv.Close()
target := strings.TrimPrefix(srv.URL, "https://")
sum := sha256.Sum256(srv.Certificate().Raw)
want := hex.EncodeToString(sum[:])
t.Run("loopback refused without opt-in", func(t *testing.T) {
hashes, err := (&ServerService{}).GetRemoteCertHash(target, false)
if !errors.Is(err, netsafe.ErrPrivateAddressBlocked) {
t.Fatalf("GetRemoteCertHash(%s) = %v, %v; want ErrPrivateAddressBlocked", target, hashes, err)
}
})
t.Run("loopback read with opt-in", func(t *testing.T) {
hashes, err := (&ServerService{}).GetRemoteCertHash(target, true)
if err != nil {
t.Fatalf("GetRemoteCertHash(%s, allowPrivate): %v", target, err)
}
if len(hashes) != 1 || hashes[0] != want {
t.Fatalf("hashes = %v, want [%s]", hashes, want)
}
})
}