fix(clients): reject dot names and recover legacy clients (#7062)

Reject reserved dot-segment client names and recover legacy records through query aliases. Preserve token scopes and path identity precedence, and update the generated API reference.
This commit is contained in:
Benjamin Mitchell
2026-11-05 09:02:39 -08:00
committed by GitHub
parent 2541080c91
commit cd176faf4e
29 changed files with 895 additions and 17 deletions
+2
View File
@@ -105,8 +105,10 @@ var nodeSyncScopeAllow = map[string]map[string]struct{}{
"/inbounds/:id/subSortIndex": {http.MethodPost: {}},
"/clients/add": {http.MethodPost: {}},
"/clients/del/:email": {http.MethodPost: {}},
"/clients/del": {http.MethodPost: {}},
"/clients/:email/detach": {http.MethodPost: {}},
"/clients/update/:email": {http.MethodPost: {}},
"/clients/update": {http.MethodPost: {}},
"/server/restartXrayService": {http.MethodPost: {}},
"/server/getWebCertFiles": {http.MethodGet: {}},
"/server/descendants": {http.MethodGet: {}},
+28 -3
View File
@@ -53,6 +53,7 @@ func NewClientController(g *gin.RouterGroup) *ClientController {
func (a *ClientController) initRouter(g *gin.RouterGroup) {
g.GET("/list", a.list)
g.GET("/list/paged", a.listPaged)
g.GET("/get", a.get)
g.GET("/get/:email", a.get)
g.GET("/get/subId/:subId", a.getBySubId)
g.GET("/get/tgId/:tgId", a.getByTgId)
@@ -63,7 +64,9 @@ func (a *ClientController) initRouter(g *gin.RouterGroup) {
g.POST("/add", a.create)
g.POST("/renewalPreview", a.renewalPreview)
g.POST("/update", a.update)
g.POST("/update/:email", a.update)
g.POST("/del", a.delete)
g.POST("/del/:email", a.delete)
g.POST("/:email/attach", a.attach)
g.POST("/:email/detach", a.detach)
@@ -159,8 +162,24 @@ func (a *ClientController) buildClientPayload(rec *model.ClientRecord) (gin.H, e
}, nil
}
func (a *ClientController) get(c *gin.Context) {
// Query aliases keep legacy dot-segment names addressable without changing path clients.
func clientEmailParam(c *gin.Context) (string, bool) {
email := c.Param("email")
if email == "" {
email = c.Query("email")
}
if email == "" {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), errors.New("client email is required"))
return "", false
}
return email, true
}
func (a *ClientController) get(c *gin.Context) {
email, ok := clientEmailParam(c)
if !ok {
return
}
rec, err := a.clientService.GetRecordByEmail(nil, email)
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.obtain"), err)
@@ -244,7 +263,10 @@ func (a *ClientController) create(c *gin.Context) {
}
func (a *ClientController) update(c *gin.Context) {
email := c.Param("email")
email, ok := clientEmailParam(c)
if !ok {
return
}
var req struct {
model.Client
LimitHwid int `json:"limitHwid"`
@@ -273,7 +295,10 @@ func (a *ClientController) update(c *gin.Context) {
}
func (a *ClientController) delete(c *gin.Context) {
email := c.Param("email")
email, ok := clientEmailParam(c)
if !ok {
return
}
keepTraffic := c.Query("keepTraffic") == "1"
needRestart, err := a.clientService.DeleteByEmail(&a.inboundService, email, keepTraffic)
// Flagged before the error check: a partly-applied delete already removed
@@ -0,0 +1,216 @@
package controller
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"path/filepath"
"strconv"
"strings"
"testing"
"github.com/gin-gonic/gin"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/dbtest"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/web/entity"
"github.com/mhsanaei/3x-ui/v3/internal/web/locale"
"github.com/mhsanaei/3x-ui/v3/internal/xray"
)
func seedLegacyDotClient(t *testing.T, email string) (*gin.Engine, model.ClientRecord, model.Inbound) {
t.Helper()
dir := t.TempDir()
t.Setenv("XUI_DB_FOLDER", dir)
dbtest.InitDB(t, filepath.Join(dir, "x-ui.db"))
db := database.GetDB()
rec := model.ClientRecord{Email: email, UUID: "11111111-2222-3333-4444-555555555555", SubID: "legacy-dot-sub", Enable: true}
if err := db.Create(&rec).Error; err != nil {
t.Fatal(err)
}
settings, err := json.Marshal(map[string]any{
"clients": []model.Client{*rec.ToClient()},
"decryption": "none",
})
if err != nil {
t.Fatal(err)
}
ib := model.Inbound{Tag: "legacy-dot", Port: 443, Protocol: model.VLESS, Settings: string(settings), StreamSettings: `{"network":"tcp","security":"none"}`}
if err := db.Create(&ib).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&model.ClientInbound{ClientId: rec.Id, InboundId: ib.Id}).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&xray.ClientTraffic{Email: email, InboundId: ib.Id, Up: 111, Down: 222, Enable: true}).Error; err != nil {
t.Fatal(err)
}
router := gin.New()
router.Use(func(c *gin.Context) {
c.Set("I18n", func(_ locale.I18nType, key string, _ ...string) string { return key })
c.Next()
})
NewClientController(router.Group("/base/panel/api/clients"))
return router, rec, ib
}
func dotClientRequest(t *testing.T, router *gin.Engine, method, path string, body any) *httptest.ResponseRecorder {
t.Helper()
payload, err := json.Marshal(body)
if err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(method, "/base/panel/api/clients/"+path, bytes.NewReader(payload))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
return w
}
func assertDotClientSuccess(t *testing.T, w *httptest.ResponseRecorder) {
t.Helper()
var msg entity.Msg
if err := json.Unmarshal(w.Body.Bytes(), &msg); err != nil {
t.Fatal(err)
}
if w.Code != http.StatusOK || !msg.Success {
t.Fatalf("status/body = %d/%s", w.Code, w.Body.String())
}
}
func TestClientQueryAliasRecoversLegacyDotName(t *testing.T) {
for _, email := range []string{".", ".."} {
t.Run(email, func(t *testing.T) {
router, rec, ib := seedLegacyDotClient(t, email)
query := "?email=" + url.QueryEscape(email)
w := dotClientRequest(t, router, http.MethodGet, "get"+query, nil)
assertDotClientSuccess(t, w)
var full struct {
Obj struct {
Client model.ClientRecord `json:"client"`
InboundIDs []int `json:"inboundIds"`
UsedTraffic int64 `json:"usedTraffic"`
} `json:"obj"`
}
if err := json.Unmarshal(w.Body.Bytes(), &full); err != nil {
t.Fatal(err)
}
if full.Obj.Client.Email != email || full.Obj.Client.UUID != rec.UUID || len(full.Obj.InboundIDs) != 1 || full.Obj.InboundIDs[0] != ib.Id || full.Obj.UsedTraffic != 333 {
t.Fatalf("unexpected legacy payload: %s", w.Body.String())
}
updated := rec.ToClient()
updated.Email = "restored-client"
w = dotClientRequest(t, router, http.MethodPost, "update"+query, updated)
assertDotClientSuccess(t, w)
db := database.GetDB()
var after model.ClientRecord
if err := db.First(&after, rec.Id).Error; err != nil {
t.Fatal(err)
}
if after.Email != updated.Email || after.UUID != rec.UUID || after.SubID != rec.SubID {
t.Fatalf("rename changed identity: %+v", after)
}
if err := db.First(&ib, ib.Id).Error; err != nil {
t.Fatal(err)
}
var settings struct {
Clients []model.Client `json:"clients"`
}
if err := json.Unmarshal([]byte(ib.Settings), &settings); err != nil {
t.Fatal(err)
}
if len(settings.Clients) != 1 || settings.Clients[0].Email != updated.Email || settings.Clients[0].ID != rec.UUID {
t.Fatalf("rename lost inbound credentials: %s", ib.Settings)
}
var traffic xray.ClientTraffic
if err := db.Where("email = ?", updated.Email).First(&traffic).Error; err != nil {
t.Fatal(err)
}
if traffic.Up != 111 || traffic.Down != 222 {
t.Fatalf("rename lost usage: %+v", traffic)
}
})
}
}
func TestClientQueryAliasDeletesLegacyDotName(t *testing.T) {
for _, email := range []string{".", ".."} {
for _, keep := range []bool{false, true} {
t.Run(email+"/keep="+strconv.FormatBool(keep), func(t *testing.T) {
router, rec, ib := seedLegacyDotClient(t, email)
path := "del?email=" + url.QueryEscape(email)
if keep {
path += "&keepTraffic=1"
}
w := dotClientRequest(t, router, http.MethodPost, path, nil)
assertDotClientSuccess(t, w)
db := database.GetDB()
var count int64
if err := db.Model(&model.ClientRecord{}).Where("id = ?", rec.Id).Count(&count).Error; err != nil || count != 0 {
t.Fatalf("client remains: count=%d err=%v", count, err)
}
if err := db.Model(&model.ClientInbound{}).Where("client_id = ?", rec.Id).Count(&count).Error; err != nil || count != 0 {
t.Fatalf("attachment remains: count=%d err=%v", count, err)
}
if err := db.First(&ib, ib.Id).Error; err != nil {
t.Fatal(err)
}
var settings struct {
Clients []model.Client `json:"clients"`
}
if err := json.Unmarshal([]byte(ib.Settings), &settings); err != nil || len(settings.Clients) != 0 {
t.Fatalf("inbound client remains: %s err=%v", ib.Settings, err)
}
if err := db.Model(&xray.ClientTraffic{}).Where("email = ?", email).Count(&count).Error; err != nil {
t.Fatal(err)
}
var want int64
if keep {
want = 1
}
if count != want {
t.Fatalf("retained traffic count=%d keep=%v", count, keep)
}
})
}
}
}
func TestClientQueryAliasRequiresEmail(t *testing.T) {
router, _, _ := seedLegacyDotClient(t, ".")
for _, route := range []struct {
method, path string
}{
{http.MethodGet, "get"}, {http.MethodPost, "update"}, {http.MethodPost, "del"},
} {
w := dotClientRequest(t, router, route.method, route.path, map[string]any{"email": "must-not-be-used-as-target"})
var msg entity.Msg
if err := json.Unmarshal(w.Body.Bytes(), &msg); err != nil {
t.Fatal(err)
}
if msg.Success || !strings.Contains(msg.Msg, "client email is required") {
t.Fatalf("missing query accepted: %s", w.Body.String())
}
}
}
func TestClientPathEmailTakesPrecedenceOverQuery(t *testing.T) {
router, legacy, _ := seedLegacyDotClient(t, ".")
normal := model.ClientRecord{Email: "audit-client", UUID: "normal-uuid", SubID: "normal-sub", Enable: true}
db := database.GetDB()
if err := db.Create(&normal).Error; err != nil {
t.Fatal(err)
}
w := dotClientRequest(t, router, http.MethodPost, "del/audit-client?email=.", nil)
assertDotClientSuccess(t, w)
var count int64
if err := db.Model(&model.ClientRecord{}).Where("id = ?", normal.Id).Count(&count).Error; err != nil || count != 0 {
t.Fatalf("path client remains: count=%d err=%v", count, err)
}
if err := db.Model(&model.ClientRecord{}).Where("id = ?", legacy.Id).Count(&count).Error; err != nil || count != 1 {
t.Fatalf("query overrode the path identity: count=%d err=%v", count, err)
}
}
@@ -152,3 +152,43 @@ func TestClientGetBySubIDRequiresAdmin(t *testing.T) {
})
}
}
func TestClientQueryAliasTokenScopes(t *testing.T) {
router, api := newAPIAuthTestEngine(t)
NewClientController(router.Group("/base/panel/api/clients", api.checkAPIAuth, api.enforceTokenScope))
for _, scope := range []string{model.ApiScopeAdmin, model.ApiScopeMonitor, model.ApiScopeNodeSync, model.ApiScopeNodeAdmin} {
if err := database.GetDB().Create(&model.ApiToken{Name: scope, Token: crypto.HashTokenSHA256(scope), Enabled: true, Scope: scope}).Error; err != nil {
t.Fatal(err)
}
}
for _, route := range []struct {
method string
path string
}{
{http.MethodGet, "get"},
{http.MethodPost, "update"},
{http.MethodPost, "del"},
} {
for _, scope := range []string{"", model.ApiScopeAdmin, model.ApiScopeMonitor, model.ApiScopeNodeSync, model.ApiScopeNodeAdmin} {
t.Run(route.path+"/scope="+scope, func(t *testing.T) {
req := httptest.NewRequest(route.method, "/base/panel/api/clients/"+route.path+"?email=missing", strings.NewReader(`{"email":"restored-client"}`))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Requested-With", "XMLHttpRequest")
if scope != "" {
req.Header.Set("Authorization", "Bearer "+scope)
}
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
want := http.StatusOK
if scope == "" {
want = http.StatusUnauthorized
} else if scope == model.ApiScopeMonitor || (route.method == http.MethodGet && scope != model.ApiScopeAdmin) {
want = http.StatusForbidden
}
if w.Code != want {
t.Fatalf("status = %d, want %d, body = %s", w.Code, want, w.Body.String())
}
})
}
}
}