diff --git a/internal/web/service/client_delete_tombstone_test.go b/internal/web/service/client_delete_tombstone_test.go index 262177378..62d5e2e3d 100644 --- a/internal/web/service/client_delete_tombstone_test.go +++ b/internal/web/service/client_delete_tombstone_test.go @@ -3,7 +3,9 @@ package service import ( "errors" "fmt" + "strings" "testing" + "time" "gorm.io/gorm" @@ -197,3 +199,29 @@ func TestRolledBackCreateKeepsTombstone(t *testing.T) { t.Fatal("a create that rolled back withdrew the delete tombstone, so a stale node snapshot can resurrect the deleted client") } } + +// The email reaches a LIKE pre-filter: a long near-miss costs SQLite text times pattern +// length while holding the database, and wildcards must not match either. +func TestFindInboundIdsByClientEmailTreatsTheEmailLiterally(t *testing.T) { + setupConflictDB(t) + settings := `{"clients":[{"email":"real@x"}],"pad":"` + strings.Repeat("a", 1<<20) + `"}` + if err := database.GetDB().Create(&model.Inbound{UserId: 1, Tag: "big", Port: 41601, Protocol: model.VLESS, Settings: settings}).Error; err != nil { + t.Fatalf("seed inbound: %v", err) + } + done := make(chan []int, 1) + go func() { + ids, _ := (&ClientService{}).findInboundIdsByClientEmail(strings.Repeat("a", 10000) + "b") + done <- ids + }() + select { + case ids := <-done: + if len(ids) != 0 { + t.Fatalf("a wildcard email matched inbounds %v", ids) + } + case <-time.After(5 * time.Second): + t.Fatal("the email lookup is still running after 5 s") + } + if ids, err := (&ClientService{}).findInboundIdsByClientEmail("real@x"); err != nil || len(ids) != 1 { + t.Fatalf("lookup of a real email = %v, %v", ids, err) + } +} diff --git a/internal/web/service/client_lookup.go b/internal/web/service/client_lookup.go index 50c5cb847..29eb990d9 100644 --- a/internal/web/service/client_lookup.go +++ b/internal/web/service/client_lookup.go @@ -4,6 +4,7 @@ import ( "encoding/json" "errors" "strings" + "unicode/utf8" "github.com/mhsanaei/3x-ui/v3/internal/database" "github.com/mhsanaei/3x-ui/v3/internal/database/model" @@ -247,14 +248,26 @@ func (s *ClientService) HasPendingNode(inboundSvc *InboundService, email string) return inboundSvc.AnyNodePending(ids) } +const settingsProbeBytes = 64 + // findInboundIdsByClientEmail returns every inbound whose settings.clients[] // JSON contains an entry with the given email. Driver-portable (no JSON // operators) by parsing in Go — fine for the rare fallback path. func (s *ClientService) findInboundIdsByClientEmail(email string) ([]int, error) { + // A literal, bounded probe: the email comes from a request, and LIKE costs the settings + // length times the pattern length; the exact match below decides. + probe := email + if len(probe) > settingsProbeBytes { + cut := settingsProbeBytes + for cut > 0 && !utf8.RuneStart(probe[cut]) { + cut-- + } + probe = probe[:cut] + } var inbounds []model.Inbound if err := database.GetDB(). Select("id, settings"). - Where("settings LIKE ?", "%"+email+"%"). + Where("settings LIKE ? ESCAPE '\\'", "%"+escapeLikeLiteral(probe)+"%"). Find(&inbounds).Error; err != nil { return nil, err }