fix(clients): probe inbound settings with a short literal pattern

Deleting a client by an email no record holds fell back to a settings
LIKE '%email%' over every inbound with the raw route parameter, which
costs SQLite the settings length times the pattern length while it holds
the database; a long near-miss email froze the panel for minutes, and
wildcards were not escaped. The pre-filter now uses the first 64 bytes
of the email, escaped; the exact client match after it still decides.
This commit is contained in:
MHSanaei
2026-10-31 14:24:28 +01:00
parent a69f2dee13
commit 44a502a407
2 changed files with 42 additions and 1 deletions
@@ -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)
}
}
+14 -1
View File
@@ -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
}