From 08482154c2d63955a4abbcff2a9e24de5f996f8c Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sat, 31 Oct 2026 09:14:57 +0000 Subject: [PATCH] fix(node): keep master requests on the node they were sent to The master's node clients followed redirects to any host, presenting the same panel-wide credentials (the mTLS client certificate) and replaying the request there, so one node could have the master push to another. Node clients and the certificate-pin probe now stop at a redirect off the original host:port and report the 3xx as the node's answer. The probe change is wiring; TestNodeClientsDoNotFollowARedirectToAnotherHost covers the guard. --- internal/web/runtime/tls_client.go | 12 +++++++++- internal/web/runtime/tls_client_test.go | 31 +++++++++++++++++++++++++ internal/web/service/node.go | 2 ++ 3 files changed, 44 insertions(+), 1 deletion(-) diff --git a/internal/web/runtime/tls_client.go b/internal/web/runtime/tls_client.go index 77073856c..a9db7260a 100644 --- a/internal/web/runtime/tls_client.go +++ b/internal/web/runtime/tls_client.go @@ -152,7 +152,16 @@ func newNodeTransport(tlsCfg *tls.Config) *http.Transport { // defaultNodeHTTPClient reaches nodes trusting the system CA store ("verify" // mode or plain http); shared so connections pool across nodes. -var defaultNodeHTTPClient = &http.Client{Transport: newNodeTransport(nil)} +var defaultNodeHTTPClient = &http.Client{Transport: newNodeTransport(nil), CheckRedirect: NoCrossHostRedirect} + +// NoCrossHostRedirect keeps a request on the host:port it was sent to: the master presents +// the same credentials to every node, so a node's Location must not carry them elsewhere. +func NoCrossHostRedirect(req *http.Request, via []*http.Request) error { + if len(via) >= 10 || !strings.EqualFold(req.URL.Host, via[0].URL.Host) { + return http.ErrUseLastResponse + } + return nil +} // nodeClients caches one client per node: heartbeat and traffic sync reach it // every few seconds, and a rebuilt client would open its own empty pool. @@ -214,6 +223,7 @@ func HTTPClientForNode(n *model.Node, proxyURL string) (*http.Client, error) { if err != nil { return nil, err } + client.CheckRedirect = NoCrossHostRedirect nodeClientsMu.Lock() if entry, ok := nodeClientsCache[key]; ok { diff --git a/internal/web/runtime/tls_client_test.go b/internal/web/runtime/tls_client_test.go index 2d151b81c..1bae8063f 100644 --- a/internal/web/runtime/tls_client_test.go +++ b/internal/web/runtime/tls_client_test.go @@ -662,3 +662,34 @@ func TestDecodeCertPin(t *testing.T) { }) } } + +// The master presents the same credentials to every node; a node's redirect must not +// carry a request (a push, the mTLS certificate) to another host. +func TestNodeClientsDoNotFollowARedirectToAnotherHost(t *testing.T) { + var otherHit atomic.Int32 + other := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { otherHit.Add(1) })) + defer other.Close() + hostile := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, other.URL+"/panel/api/inbounds/update/1", http.StatusTemporaryRedirect) + })) + defer hostile.Close() + u, _ := url.Parse(hostile.URL) + port, _ := strconv.Atoi(u.Port()) + for _, node := range []*model.Node{ + {Id: 41, Address: u.Hostname(), Port: port, Scheme: "https", TlsVerifyMode: "skip", AllowPrivateAddress: true}, + {Id: 42, Address: u.Hostname(), Port: port, Scheme: "http", AllowPrivateAddress: true}, + } { + client, err := HTTPClientForNode(node, "") + if err != nil { + t.Fatalf("HTTPClientForNode: %v", err) + } + req, _ := http.NewRequestWithContext(netsafe.ContextWithAllowPrivate(context.Background(), true), + http.MethodPost, hostile.URL+"/panel/api/inbounds/list", strings.NewReader("{}")) + if resp, err := client.Do(req); err == nil { + _ = resp.Body.Close() + } + } + if otherHit.Load() != 0 { + t.Fatalf("a node's redirect reached another host %d time(s)", otherHit.Load()) + } +} diff --git a/internal/web/service/node.go b/internal/web/service/node.go index a3463966e..ad0e1a3b1 100644 --- a/internal/web/service/node.go +++ b/internal/web/service/node.go @@ -90,6 +90,8 @@ func (s *NodeService) FetchCertFingerprint(ctx context.Context, n *model.Node) ( DialContext: netsafe.SSRFGuardedDialContext, TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, // lgtm[go/disabled-certificate-check] }, + // The pin must be the certificate of the host asked, not of wherever it redirects. + CheckRedirect: runtime.NoCrossHostRedirect, } defer client.CloseIdleConnections() resp, err := client.Do(req)