From 567a4ac4fee23af3fd651a0ef5d96148a70f8b42 Mon Sep 17 00:00:00 2001 From: n0ctal <4c866w5fn9@privaterelay.appleid.com> Date: Wed, 8 Jul 2026 23:31:00 +0500 Subject: [PATCH] fix(clients): parse only settings.clients across protocols (#5855) * fix(clients): parse only settings.clients across protocols Several inbound settings readers decoded the whole settings object into map[string][]model.Client. Real protocol settings include scalar keys such as VLESS decryption and Hysteria version, so that shape can fail before callers reach settings.clients or leave them relying on decoder side effects. Add one shared helper that extracts only the clients field through json.RawMessage, then use it from GetClients, SearchClientTraffic and the IP-limit job fallback paths. Regression tests cover VLESS and Hysteria settings with scalar protocol fields. * fix(clients): reject empty inbound settings --- internal/web/job/check_client_ip_job.go | 10 +- internal/web/job/check_client_ip_job_test.go | 37 ++++++ internal/web/service/inbound.go | 12 +- .../web/service/inbound_settings_clients.go | 32 +++++ .../service/inbound_settings_clients_test.go | 124 ++++++++++++++++++ internal/web/service/inbound_traffic.go | 6 +- 6 files changed, 200 insertions(+), 21 deletions(-) create mode 100644 internal/web/service/inbound_settings_clients.go create mode 100644 internal/web/service/inbound_settings_clients_test.go diff --git a/internal/web/job/check_client_ip_job.go b/internal/web/job/check_client_ip_job.go index e016f64f7..fb7ad89f0 100644 --- a/internal/web/job/check_client_ip_job.go +++ b/internal/web/job/check_client_ip_job.go @@ -357,9 +357,7 @@ func (j *CheckClientIpJob) processObserved(observed map[string]map[string]int64, for _, d := range disconnects { clients, cached := clientsCache[d.inbound.Id] if !cached { - settings := map[string][]model.Client{} - _ = json.Unmarshal([]byte(d.inbound.Settings), &settings) - clients = settings["clients"] + clients, _ = service.ParseInboundSettingsClients(d.inbound.Settings) clientsCache[d.inbound.Id] = clients } j.disconnectClientTemporarily(d.inbound, d.email, clients) @@ -702,11 +700,11 @@ func (j *CheckClientIpJob) getInboundByEmail(clientEmail string) (*model.Inbound return nil, listErr } for i := range candidates { - settings := map[string][]model.Client{} - if jsonErr := json.Unmarshal([]byte(candidates[i].Settings), &settings); jsonErr != nil { + clients, jsonErr := service.ParseInboundSettingsClients(candidates[i].Settings) + if jsonErr != nil { continue } - for _, client := range settings["clients"] { + for _, client := range clients { if client.Email == clientEmail { return &candidates[i], nil } diff --git a/internal/web/job/check_client_ip_job_test.go b/internal/web/job/check_client_ip_job_test.go index 707689e58..7670b55b8 100644 --- a/internal/web/job/check_client_ip_job_test.go +++ b/internal/web/job/check_client_ip_job_test.go @@ -7,6 +7,9 @@ import ( "runtime" "testing" "time" + + "github.com/mhsanaei/3x-ui/v3/internal/database" + "github.com/mhsanaei/3x-ui/v3/internal/database/model" ) func TestMergeClientIps_EvictsStaleOldEntries(t *testing.T) { @@ -191,6 +194,40 @@ func TestPartitionLiveIps_ConcurrentLiveIpsSortedAscending(t *testing.T) { } } +func TestGetInboundByEmailFallbackIgnoresProtocolScalarFields(t *testing.T) { + dbDir := t.TempDir() + t.Setenv("XUI_DB_FOLDER", dbDir) + if err := database.InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil { + t.Fatalf("InitDB: %v", err) + } + t.Cleanup(func() { _ = database.CloseDB() }) + + inbound := &model.Inbound{ + UserId: 1, + Tag: "vless-limit-fallback", + Enable: true, + Port: 43002, + Protocol: model.VLESS, + Settings: `{ + "clients": [{"email": "alice@example.test", "id": "11111111-1111-1111-1111-111111111111", "limitIp": 2}], + "decryption": "none", + "encryption": "none", + "fallbacks": [] + }`, + } + if err := database.GetDB().Create(inbound).Error; err != nil { + t.Fatalf("create inbound: %v", err) + } + + got, err := (&CheckClientIpJob{}).getInboundByEmail("alice@example.test") + if err != nil { + t.Fatalf("getInboundByEmail: %v", err) + } + if got.Id != inbound.Id { + t.Fatalf("inbound id = %d, want %d", got.Id, inbound.Id) + } +} + func TestPartitionLiveIps_EmptyScanLeavesDbIntact(t *testing.T) { // quiet tick: nothing observed => nothing live. everything merged // is historical. keeps the panel from wiping recent-but-idle ips. diff --git a/internal/web/service/inbound.go b/internal/web/service/inbound.go index a782ae85a..cafd52778 100644 --- a/internal/web/service/inbound.go +++ b/internal/web/service/inbound.go @@ -440,17 +440,7 @@ func (s *InboundService) GetInboundsByTrafficReset(period string) ([]*model.Inbo } func (s *InboundService) GetClients(inbound *model.Inbound) ([]model.Client, error) { - settings := map[string][]model.Client{} - _ = json.Unmarshal([]byte(inbound.Settings), &settings) - if settings == nil { - return nil, fmt.Errorf("setting is null") - } - - clients := settings["clients"] - if clients == nil { - return nil, nil - } - return clients, nil + return ParseInboundSettingsClients(inbound.Settings) } // GetClientsBySubId returns the inbound's clients with the given subscription diff --git a/internal/web/service/inbound_settings_clients.go b/internal/web/service/inbound_settings_clients.go new file mode 100644 index 000000000..1df211811 --- /dev/null +++ b/internal/web/service/inbound_settings_clients.go @@ -0,0 +1,32 @@ +package service + +import ( + "encoding/json" + "strings" + + "github.com/mhsanaei/3x-ui/v3/internal/database/model" + "github.com/mhsanaei/3x-ui/v3/internal/util/common" +) + +func ParseInboundSettingsClients(settings string) ([]model.Client, error) { + trimmed := strings.TrimSpace(settings) + if trimmed == "" || trimmed == "null" { + return nil, common.NewError("inbound settings is empty") + } + + var payload struct { + Clients json.RawMessage `json:"clients"` + } + if err := json.Unmarshal([]byte(trimmed), &payload); err != nil { + return nil, err + } + if len(payload.Clients) == 0 || string(payload.Clients) == "null" { + return nil, nil + } + + var clients []model.Client + if err := json.Unmarshal(payload.Clients, &clients); err != nil { + return nil, err + } + return clients, nil +} diff --git a/internal/web/service/inbound_settings_clients_test.go b/internal/web/service/inbound_settings_clients_test.go new file mode 100644 index 000000000..598161e9e --- /dev/null +++ b/internal/web/service/inbound_settings_clients_test.go @@ -0,0 +1,124 @@ +package service + +import ( + "testing" + + "github.com/mhsanaei/3x-ui/v3/internal/database" + "github.com/mhsanaei/3x-ui/v3/internal/database/model" + "github.com/mhsanaei/3x-ui/v3/internal/xray" +) + +func TestParseInboundSettingsClientsIgnoresProtocolScalarFields(t *testing.T) { + tests := []struct { + name string + settings string + want string + }{ + { + name: "vless scalar settings", + settings: `{ + "clients": [{"email": "alice@example.test", "id": "11111111-1111-1111-1111-111111111111", "limitIp": 2}], + "decryption": "none", + "encryption": "none", + "fallbacks": [] + }`, + want: "alice@example.test", + }, + { + name: "hysteria scalar settings", + settings: `{ + "clients": [{"email": "bob@example.test", "password": "secret"}], + "version": 2, + "ignoreClientBandwidth": false + }`, + want: "bob@example.test", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + clients, err := ParseInboundSettingsClients(tt.settings) + if err != nil { + t.Fatalf("ParseInboundSettingsClients: %v", err) + } + if len(clients) != 1 || clients[0].Email != tt.want { + t.Fatalf("clients = %+v, want one client with email %s", clients, tt.want) + } + }) + } +} + +func TestParseInboundSettingsClientsRejectsEmptyOrNullSettings(t *testing.T) { + for _, settings := range []string{"", " ", "null", " \n null \t "} { + t.Run(settings, func(t *testing.T) { + clients, err := ParseInboundSettingsClients(settings) + if err == nil { + t.Fatalf("ParseInboundSettingsClients(%q) error = nil, want error", settings) + } + if clients != nil { + t.Fatalf("clients = %+v, want nil", clients) + } + }) + } +} + +func TestGetClientsIgnoresProtocolScalarFields(t *testing.T) { + inbound := &model.Inbound{ + Settings: `{ + "clients": [{"email": "alice@example.test", "id": "11111111-1111-1111-1111-111111111111"}], + "decryption": "none", + "encryption": "none", + "fallbacks": [] + }`, + } + + clients, err := (&InboundService{}).GetClients(inbound) + if err != nil { + t.Fatalf("GetClients: %v", err) + } + if len(clients) != 1 || clients[0].Email != "alice@example.test" { + t.Fatalf("clients = %+v, want alice@example.test", clients) + } +} + +func TestSearchClientTrafficIgnoresProtocolScalarFields(t *testing.T) { + setupBulkDB(t) + db := database.GetDB() + + client := model.Client{ + Email: "alice@example.test", + ID: "11111111-1111-1111-1111-111111111111", + SubID: "sub-alice", + Enable: true, + } + inbound := &model.Inbound{ + UserId: 1, + Tag: "vless-scalar", + Enable: true, + Port: 43001, + Protocol: model.VLESS, + Settings: `{ + "clients": [{"email": "alice@example.test", "id": "11111111-1111-1111-1111-111111111111", "subId": "sub-alice", "enable": true}], + "decryption": "none", + "encryption": "none", + "fallbacks": [] + }`, + } + if err := db.Create(inbound).Error; err != nil { + t.Fatalf("create inbound: %v", err) + } + if err := db.Create(&model.ClientRecord{Email: client.Email, Enable: true, SubID: client.SubID}).Error; err != nil { + t.Fatalf("create client record: %v", err) + } + if err := db.Create(&xray.ClientTraffic{InboundId: inbound.Id, Email: client.Email, Enable: true}).Error; err != nil { + t.Fatalf("create client traffic: %v", err) + } + + traffic, err := (&InboundService{}).SearchClientTraffic(client.ID) + if err != nil { + t.Fatalf("SearchClientTraffic: %v", err) + } + if traffic.Email != client.Email || traffic.InboundId != inbound.Id { + t.Fatalf("traffic = %+v, want email %s inbound %d", traffic, client.Email, inbound.Id) + } +} diff --git a/internal/web/service/inbound_traffic.go b/internal/web/service/inbound_traffic.go index adeb61b5c..8fb041c26 100644 --- a/internal/web/service/inbound_traffic.go +++ b/internal/web/service/inbound_traffic.go @@ -1069,14 +1069,12 @@ func (s *InboundService) SearchClientTraffic(query string) (traffic *xray.Client traffic.InboundId = inbound.Id - // Unmarshal settings to get clients - settings := map[string][]model.Client{} - if err := json.Unmarshal([]byte(inbound.Settings), &settings); err != nil { + clients, err := ParseInboundSettingsClients(inbound.Settings) + if err != nil { logger.Errorf("Error unmarshalling inbound settings for inbound ID %d: %v", inbound.Id, err) return nil, err } - clients := settings["clients"] for _, client := range clients { if (client.ID == query || client.Password == query) && client.Email != "" { traffic.Email = client.Email