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.
This commit is contained in:
MHSanaei
2026-10-31 09:22:24 +00:00
parent 9cf8c87e8d
commit 87f3f54ea6
7 changed files with 67 additions and 2 deletions
@@ -1,6 +1,7 @@
package service package service
import ( import (
"strings"
"testing" "testing"
"time" "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) 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)
}
}
+1 -1
View File
@@ -561,7 +561,7 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
selfKey := effectiveNodeKey(&model.Node{Id: nodeID, Guid: nodeRow.Guid}) selfKey := effectiveNodeKey(&model.Node{Id: nodeID, Guid: nodeRow.Guid})
guidShared := nodeRow.Guid != "" && selfKey != nodeRow.Guid guidShared := nodeRow.Guid != "" && selfKey != nodeRow.Guid
originGuidFor := func(snapIb *model.Inbound) string { 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 snapIb.OriginNodeGuid
} }
return selfKey return selfKey
+4
View File
@@ -139,6 +139,10 @@ func (s *InboundService) MergeClientIpsByGuid(node *model.Node, trees map[string
} }
} }
for guid, perEmail := range trees { 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 { if err := upsertNodeClientIps(guid, perEmail); err != nil {
return err return err
} }
@@ -19,7 +19,7 @@ const globalTrafficHeartbeatInterval = time.Hour
// Identical usage refreshes hourly; client_traffics remains local-only for delta accounting. // Identical usage refreshes hourly; client_traffics remains local-only for delta accounting.
func (s *InboundService) AcceptGlobalTraffic(masterGuid string, traffics []*xray.ClientTraffic) error { func (s *InboundService) AcceptGlobalTraffic(masterGuid string, traffics []*xray.ClientTraffic) error {
masterGuid = strings.TrimSpace(masterGuid) masterGuid = strings.TrimSpace(masterGuid)
if masterGuid == "" { if masterGuid == "" || len(masterGuid) > maxPanelGuidLen {
return nil return nil
} }
emails := make([]string, 0, len(traffics)) emails := make([]string, 0, len(traffics))
+8
View File
@@ -1285,6 +1285,10 @@ func (b *outboundBridge) removeInbound() {
// broken node from dictating the master's allocation on every heartbeat. // broken node from dictating the master's allocation on every heartbeat.
const maxProbeBodyBytes = 1 << 20 // 1 MiB 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) { func (s *NodeService) probe(ctx context.Context, n *model.Node, proxyURL string) (HeartbeatPatch, error) {
patch := HeartbeatPatch{LastHeartbeat: time.Now().Unix()} 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.XrayState = o.Xray.State
patch.XrayError = o.Xray.ErrorMsg patch.XrayError = o.Xray.ErrorMsg
patch.PanelVersion = o.PanelVersion 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.Guid = o.PanelGuid
patch.UptimeSecs = o.Uptime patch.UptimeSecs = o.Uptime
patch.NetUp = o.NetIO.Up patch.NetUp = o.NetIO.Up
@@ -2,6 +2,7 @@ package service
import ( import (
"slices" "slices"
"strings"
"testing" "testing"
"github.com/mhsanaei/3x-ui/v3/internal/database" "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) 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)
}
}
@@ -48,3 +48,19 @@ func TestProbeRejectsOversizedStatusBody(t *testing.T) {
t.Fatalf("LastError = %q, want a \"decode response: \" rejection", patch.LastError) 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)
}
}