From c4b9b3f9c7d71e9b50efc5578df94f1302755ea4 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sun, 11 Oct 2026 16:35:20 +0200 Subject: [PATCH] fix(clients): refuse a ":" email on a MASQUE inbound MASQUE logs a user in with the email as the Basic-auth user name, so xray-core refuses the whole inbound (MASQUE: invalid "email") over one email holding ":", and with it the Xray start. The panel accepts such an email for every other protocol, so attaching or creating that client on a MASQUE inbound took every inbound down on the next restart. syncInboundClients is the write every create, attach, bulk, import and inbound-edit path shares inside its transaction, before the runtime push, so the per-protocol check there now refuses the email for MASQUE next to the existing TUIC identity check, loading the protocol once. --- internal/web/service/client_link.go | 28 ++++++++++++-- internal/web/service/inbound_masque_test.go | 41 +++++++++++++++++++++ 2 files changed, 65 insertions(+), 4 deletions(-) diff --git a/internal/web/service/client_link.go b/internal/web/service/client_link.go index 212c48962..0250127a8 100644 --- a/internal/web/service/client_link.go +++ b/internal/web/service/client_link.go @@ -105,7 +105,7 @@ func (s *ClientService) syncInboundClients(tx *gorm.DB, inboundId int, clients [ tx = database.GetDB() } - if err := s.validateTuicIdentities(tx, inboundId, clients, detachEmails, prune); err != nil { + if err := s.validateProtocolClients(tx, inboundId, clients, detachEmails, prune); err != nil { return err } @@ -416,14 +416,34 @@ func (s *ClientService) ListForInboundBySubId(tx *gorm.DB, inboundId int, subId return out, nil } -func (s *ClientService) validateTuicIdentities(tx *gorm.DB, inboundID int, changed []model.Client, detached []string, prune bool) error { +// validateProtocolClients runs inside the write every path linking clients to an inbound +// shares, so a client the protocol's core config would refuse never reaches it. +func (s *ClientService) validateProtocolClients(tx *gorm.DB, inboundID int, changed []model.Client, detached []string, prune bool) error { var inbound model.Inbound if err := tx.Select("protocol").Where("id = ?", inboundID).Take(&inbound).Error; err != nil { return err } - if inbound.Protocol != model.TUIC { - return nil + switch inbound.Protocol { + case model.TUIC: + return s.validateTuicIdentities(tx, inboundID, changed, detached, prune) + case model.MASQUE: + return validateMasqueEmails(changed) } + return nil +} + +// MASQUE sends the email as the Basic-auth user, so xray-core refuses the whole inbound, +// and with it the Xray start, over one email holding ":". +func validateMasqueEmails(clients []model.Client) error { + for _, client := range clients { + if strings.Contains(client.Email, ":") { + return fmt.Errorf("MASQUE: client email %q cannot contain \":\"", client.Email) + } + } + return nil +} + +func (s *ClientService) validateTuicIdentities(tx *gorm.DB, inboundID int, changed []model.Client, detached []string, prune bool) error { candidates := append([]model.Client(nil), changed...) if !prune { current, err := s.ListForInbound(tx, inboundID) diff --git a/internal/web/service/inbound_masque_test.go b/internal/web/service/inbound_masque_test.go index ff1688bc3..ed5aa1f2e 100644 --- a/internal/web/service/inbound_masque_test.go +++ b/internal/web/service/inbound_masque_test.go @@ -2,6 +2,7 @@ package service import ( "encoding/json" + "strings" "testing" "github.com/mhsanaei/3x-ui/v3/internal/database/model" @@ -57,6 +58,46 @@ func TestGetXrayConfig_EmitsMasqueUserPasswords(t *testing.T) { t.Fatal("inbound in-8443-masque not found in the generated config") } +// MASQUE logs users in over Basic auth, so xray-core refuses the whole inbound over one +// email holding ":"; the panel allows that email elsewhere and must refuse it here. +func TestCreateClient_RefusesColonEmailOnMasque(t *testing.T) { + setupConflictDB(t) + in := &model.Inbound{ + Tag: "in-8443-masque", Enable: true, Listen: "127.0.0.1", Port: 8443, Protocol: model.MASQUE, + Settings: `{"clients":[{"email":"ivy@example.test","password":"masque-pass-ivy","enable":true}],"address":["10.14.0.1/24"]}`, + StreamSettings: masqueTestStream, + } + if _, _, err := (&InboundService{}).AddInbound(in); err != nil { + t.Fatalf("AddInbound: %v", err) + } + + _, err := (&ClientService{}).Create(&InboundService{}, &ClientCreatePayload{ + Client: model.Client{Email: "tg:42", Enable: true}, + InboundIds: []int{in.Id}, + }) + if want := `MASQUE: client email "tg:42" cannot contain ":"`; err == nil || !strings.Contains(err.Error(), want) { + t.Fatalf("Create err = %v, want one containing %q", err, want) + } + + cfg, err := (&XrayService{}).GetXrayConfig() + if err != nil { + t.Fatalf("GetXrayConfig: %v", err) + } + for i := range cfg.InboundConfigs { + if cfg.InboundConfigs[i].Tag != in.Tag { + continue + } + raw, _ := json.Marshal(cfg.InboundConfigs[i]) + var emitted map[string]any + if err := json.Unmarshal(raw, &emitted); err != nil { + t.Fatalf("decode emitted inbound: %v", err) + } + assertXrayAccepts(t, "the emitted MASQUE inbound", buildGoldenInbound(t, emitted)) + return + } + t.Fatalf("inbound %s not found in the generated config", in.Tag) +} + // A client added to a MASQUE inbound without a password could never authenticate, // and xray-core refuses the whole inbound over one empty pass. func TestFillProtocolDefaults_MintsMasquePassword(t *testing.T) {