mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-10-04 13:12:07 +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:
@@ -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": [
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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}',
|
||||
|
||||
@@ -398,7 +398,7 @@ export default function TlsForm({
|
||||
/>
|
||||
<Button
|
||||
icon={<CloudDownloadOutlined />}
|
||||
onClick={pinFromRemote}
|
||||
onClick={() => pinFromRemote()}
|
||||
loading={saving}
|
||||
title={t('pages.inbounds.form.pinFromRemote')}
|
||||
/>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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