mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-11-05 20:55:29 +03:00
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:
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user