Bläddra i källkod

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.
MHSanaei 2 timmar sedan
förälder
incheckning
ede275e4dc

+ 4 - 0
docs/public/openapi.json

@@ -7797,6 +7797,10 @@
                   "server": {
                     "type": "string",
                     "description": "Remote server as domain or domain:port (default port 443), e.g. cloudflare-dns.com."
+                  },
+                  "allowPrivate": {
+                    "type": "boolean",
+                    "description": "Ping a private/internal/loopback server (LAN, Docker service name). Default false (SSRF guard blocks it and the error response sets obj.privateTarget=true)."
                   }
                 },
                 "required": [

+ 4 - 0
frontend/public/openapi.json

@@ -7797,6 +7797,10 @@
                   "server": {
                     "type": "string",
                     "description": "Remote server as domain or domain:port (default port 443), e.g. cloudflare-dns.com."
+                  },
+                  "allowPrivate": {
+                    "type": "boolean",
+                    "description": "Ping a private/internal/loopback server (LAN, Docker service name). Default false (SSRF guard blocks it and the error response sets obj.privateTarget=true)."
                   }
                 },
                 "required": [

+ 7 - 0
frontend/src/pages/api-docs/endpoints.ts

@@ -876,6 +876,13 @@ export const sections: readonly Section[] = [
             type: 'string',
             desc: 'Remote server as domain or domain:port (default port 443), e.g. cloudflare-dns.com.',
           },
+          {
+            name: 'allowPrivate',
+            in: 'body (form)',
+            type: 'boolean',
+            optional: true,
+            desc: 'Ping a private/internal/loopback server (LAN, Docker service name). Default false (SSRF guard blocks it and the error response sets obj.privateTarget=true).',
+          },
         ],
         body: 'server=cloudflare-dns.com',
         response: '{\n  "success": true,\n  "obj": [\n    "e8e2d3..."\n  ]\n}',

+ 1 - 1
frontend/src/pages/inbounds/form/security/tls.tsx

@@ -398,7 +398,7 @@ export default function TlsForm({
           />
           <Button
             icon={<CloudDownloadOutlined />}
-            onClick={pinFromRemote}
+            onClick={() => pinFromRemote()}
             loading={saving}
             title={t('pages.inbounds.form.pinFromRemote')}
           />

+ 18 - 2
frontend/src/pages/inbounds/form/useSecurityActions.ts

@@ -251,7 +251,7 @@ export function useSecurityActions({
    * remote certificate hash via `xray tls ping`. Useful when the panel doesn't
    * hold the cert file (a CDN front / external endpoint).
    */
-  const pinFromRemote = async () => {
+  const pinFromRemote = async (allowPrivate = false) => {
     const server = (
       (getValues('streamSettings.tlsSettings.serverName') as string | undefined) ?? ''
     ).trim();
@@ -268,7 +268,23 @@ export function useSecurityActions({
     const target = /:\d+$/.test(server) || !port ? server : `${server}:${port}`;
     setSaving(true);
     try {
-      const msg = await HttpUtil.post('/panel/api/server/getRemoteCertHash', { server: target });
+      const msg = await HttpUtil.post(
+        '/panel/api/server/getRemoteCertHash',
+        { server: target, allowPrivate },
+        { silent: true },
+      );
+      // The SSRF guard refuses a LAN/loopback endpoint until the operator confirms it.
+      const blocked = (msg?.obj as { privateTarget?: boolean } | null | undefined)?.privateTarget;
+      if (!msg?.success && blocked && !allowPrivate) {
+        modal.confirm({
+          title: t('pages.inbounds.form.scanPrivateConfirmTitle'),
+          content: t('pages.inbounds.form.scanPrivateConfirmContent', { target }),
+          okText: t('confirm'),
+          cancelText: t('cancel'),
+          onOk: () => pinFromRemote(true),
+        });
+        return;
+      }
       if (!msg?.success) {
         messageApi.warning(msg?.msg || t('pages.inbounds.form.pinFromRemoteFailed'));
         return;

+ 8 - 1
internal/web/controller/server.go

@@ -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
internal/web/service/server.go

@@ -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))

+ 38 - 0
internal/web/service/server_remote_cert_hash_test.go

@@ -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)
+		}
+	})
+}