From 87f3f54ea6626331429b5d62f29e53499cd93aa5 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sat, 31 Oct 2026 09:22:24 +0000 Subject: [PATCH] fix(node): bound the panel GUIDs a peer reports A peer's panelGuid is copied into nodes.guid and into every inbound row it reports (an indexed column), a forwarded origin GUID likewise, and a GUID-keyed IP tree or a master's global-traffic push keys one row per client. One oversized value therefore multiplied into gigabytes stored. GUIDs over 64 bytes (panels mint 36-character UUIDs) now fail the heartbeat, fall back to the reporting node's key, or are dropped. --- internal/web/service/global_traffic_test.go | 15 +++++++++++++ internal/web/service/inbound_node.go | 2 +- internal/web/service/inbound_node_ips.go | 4 ++++ .../web/service/inbound_traffic_global.go | 2 +- internal/web/service/node.go | 8 +++++++ internal/web/service/node_origin_guid_test.go | 22 +++++++++++++++++++ .../web/service/node_probe_body_cap_test.go | 16 ++++++++++++++ 7 files changed, 67 insertions(+), 2 deletions(-) diff --git a/internal/web/service/global_traffic_test.go b/internal/web/service/global_traffic_test.go index c4ce99c9b..83c935aee 100644 --- a/internal/web/service/global_traffic_test.go +++ b/internal/web/service/global_traffic_test.go @@ -1,6 +1,7 @@ package service import ( + "strings" "testing" "time" @@ -233,3 +234,17 @@ func TestSnapshotListNotOverlaid_SlimOverlaid(t *testing.T) { t.Errorf("slim list should carry the global overlay, got up=%d", slim[0].ClientStats[0].Up) } } + +// The master GUID keys one stored row per client, so an oversized one is dropped. +func TestAcceptGlobalTrafficDropsAnOversizedMasterGuid(t *testing.T) { + db := initTrafficTestDB(t) + seedClientRow(t, "alice", 1, 0, 0, 0) + if err := (&InboundService{}).AcceptGlobalTraffic(strings.Repeat("m", maxPanelGuidLen+1), []*xray.ClientTraffic{{Email: "alice", Up: 1, Down: 1}}); err != nil { + t.Fatalf("AcceptGlobalTraffic: %v", err) + } + var rows int64 + db.Model(&model.ClientGlobalTraffic{}).Count(&rows) + if rows != 0 { + t.Fatalf("stored %d row(s) under an oversized master GUID", rows) + } +} diff --git a/internal/web/service/inbound_node.go b/internal/web/service/inbound_node.go index 4e8035af9..938512196 100644 --- a/internal/web/service/inbound_node.go +++ b/internal/web/service/inbound_node.go @@ -561,7 +561,7 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi selfKey := effectiveNodeKey(&model.Node{Id: nodeID, Guid: nodeRow.Guid}) guidShared := nodeRow.Guid != "" && selfKey != nodeRow.Guid originGuidFor := func(snapIb *model.Inbound) string { - if snapIb.OriginNodeGuid != "" && snapIb.OriginNodeGuid != nodeRow.Guid { + if snapIb.OriginNodeGuid != "" && snapIb.OriginNodeGuid != nodeRow.Guid && len(snapIb.OriginNodeGuid) <= maxPanelGuidLen { return snapIb.OriginNodeGuid } return selfKey diff --git a/internal/web/service/inbound_node_ips.go b/internal/web/service/inbound_node_ips.go index 43882d55d..7ef267cab 100644 --- a/internal/web/service/inbound_node_ips.go +++ b/internal/web/service/inbound_node_ips.go @@ -139,6 +139,10 @@ func (s *InboundService) MergeClientIpsByGuid(node *model.Node, trees map[string } } for guid, perEmail := range trees { + // Stored once per email: an oversized key would multiply into every row. + if len(guid) > maxPanelGuidLen { + continue + } if err := upsertNodeClientIps(guid, perEmail); err != nil { return err } diff --git a/internal/web/service/inbound_traffic_global.go b/internal/web/service/inbound_traffic_global.go index 46b9e3dea..43eea2022 100644 --- a/internal/web/service/inbound_traffic_global.go +++ b/internal/web/service/inbound_traffic_global.go @@ -19,7 +19,7 @@ const globalTrafficHeartbeatInterval = time.Hour // Identical usage refreshes hourly; client_traffics remains local-only for delta accounting. func (s *InboundService) AcceptGlobalTraffic(masterGuid string, traffics []*xray.ClientTraffic) error { masterGuid = strings.TrimSpace(masterGuid) - if masterGuid == "" { + if masterGuid == "" || len(masterGuid) > maxPanelGuidLen { return nil } emails := make([]string, 0, len(traffics)) diff --git a/internal/web/service/node.go b/internal/web/service/node.go index ad0e1a3b1..8803005e8 100644 --- a/internal/web/service/node.go +++ b/internal/web/service/node.go @@ -1285,6 +1285,10 @@ func (b *outboundBridge) removeInbound() { // broken node from dictating the master's allocation on every heartbeat. const maxProbeBodyBytes = 1 << 20 // 1 MiB +// maxPanelGuidLen bounds a GUID a peer reports: panels mint 36-character UUIDs, and the +// value is copied into every inbound row and indexed columns. +const maxPanelGuidLen = 64 + func (s *NodeService) probe(ctx context.Context, n *model.Node, proxyURL string) (HeartbeatPatch, error) { patch := HeartbeatPatch{LastHeartbeat: time.Now().Unix()} @@ -1392,6 +1396,10 @@ func (s *NodeService) probe(ctx context.Context, n *model.Node, proxyURL string) patch.XrayState = o.Xray.State patch.XrayError = o.Xray.ErrorMsg patch.PanelVersion = o.PanelVersion + if len(o.PanelGuid) > maxPanelGuidLen { + patch.LastError = fmt.Sprintf("remote panel reported a %d-byte panelGuid", len(o.PanelGuid)) + return patch, errors.New(patch.LastError) + } patch.Guid = o.PanelGuid patch.UptimeSecs = o.Uptime patch.NetUp = o.NetIO.Up diff --git a/internal/web/service/node_origin_guid_test.go b/internal/web/service/node_origin_guid_test.go index dc3a8d79a..c577db363 100644 --- a/internal/web/service/node_origin_guid_test.go +++ b/internal/web/service/node_origin_guid_test.go @@ -2,6 +2,7 @@ package service import ( "slices" + "strings" "testing" "github.com/mhsanaei/3x-ui/v3/internal/database" @@ -448,3 +449,24 @@ func TestSetRemoteTraffic_DefaultsShareAddressFieldsForNewCentralInbound(t *test t.Fatalf("new central inbound share fields = (%q, %q), want (node, empty)", ib.ShareAddrStrategy, ib.ShareAddr) } } + +// An origin GUID is copied into the inbound row and its index; one a node forwards over +// the GUID bound is attributed to the reporting node instead of being stored. +func TestSetRemoteTraffic_RefusesAnOversizedForwardedOrigin(t *testing.T) { + setupConflictDB(t) + db := database.GetDB() + if err := db.Create(&model.Node{Id: 1, Name: "node2", Address: "10.0.0.2", Port: 2053, ApiToken: "t", Guid: "node2-guid"}).Error; err != nil { + t.Fatalf("create node: %v", err) + } + snap := &runtime.TrafficSnapshot{Inbounds: []*model.Inbound{{ + Tag: "in-443-tcp", Enable: true, Port: 443, Protocol: model.VLESS, Settings: `{"clients":[]}`, + OriginNodeGuid: strings.Repeat("g", maxPanelGuidLen+1), + }}} + if _, err := (&InboundService{}).setRemoteTrafficLocked(1, snap, false, false); err != nil { + t.Fatalf("setRemoteTrafficLocked: %v", err) + } + var ib model.Inbound + if err := db.Where("tag = ?", "in-443-tcp").First(&ib).Error; err != nil || ib.OriginNodeGuid != "node2-guid" { + t.Fatalf("origin = %d bytes (%v), want the reporting node's GUID", len(ib.OriginNodeGuid), err) + } +} diff --git a/internal/web/service/node_probe_body_cap_test.go b/internal/web/service/node_probe_body_cap_test.go index 14ce485df..13e84df1a 100644 --- a/internal/web/service/node_probe_body_cap_test.go +++ b/internal/web/service/node_probe_body_cap_test.go @@ -48,3 +48,19 @@ func TestProbeRejectsOversizedStatusBody(t *testing.T) { t.Fatalf("LastError = %q, want a \"decode response: \" rejection", patch.LastError) } } + +// A panelGuid is copied into every inbound the node reports and into indexed columns, so +// one oversized value multiplies into gigabytes of stored copies. +func TestProbeRefusesAnOversizedPanelGuid(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"success":true,"obj":{"cpuPct":1,"panelGuid":"` + strings.Repeat("g", maxPanelGuidLen+1) + `"}}`)) + })) + defer srv.Close() + u, _ := url.Parse(srv.URL) + port, _ := strconv.Atoi(u.Port()) + n := &model.Node{Id: 1, Scheme: "http", Address: u.Hostname(), Port: port, BasePath: "/", Enable: true, AllowPrivateAddress: true} + patch, err := (&NodeService{}).Probe(context.Background(), n) + if err == nil || patch.Guid != "" || !strings.Contains(patch.LastError, "panelGuid") { + t.Fatalf("Probe = guid %d bytes, %v (%q); want the heartbeat refused", len(patch.Guid), err, patch.LastError) + } +}