Browse Source

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.
MHSanaei 23 hours ago
parent
commit
c4b9b3f9c7
2 changed files with 65 additions and 4 deletions
  1. 24 4
      internal/web/service/client_link.go
  2. 41 0
      internal/web/service/inbound_masque_test.go

+ 24 - 4
internal/web/service/client_link.go

@@ -105,7 +105,7 @@ func (s *ClientService) syncInboundClients(tx *gorm.DB, inboundId int, clients [
 		tx = database.GetDB()
 		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
 		return err
 	}
 	}
 
 
@@ -416,14 +416,34 @@ func (s *ClientService) ListForInboundBySubId(tx *gorm.DB, inboundId int, subId
 	return out, nil
 	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
 	var inbound model.Inbound
 	if err := tx.Select("protocol").Where("id = ?", inboundID).Take(&inbound).Error; err != nil {
 	if err := tx.Select("protocol").Where("id = ?", inboundID).Take(&inbound).Error; err != nil {
 		return err
 		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...)
 	candidates := append([]model.Client(nil), changed...)
 	if !prune {
 	if !prune {
 		current, err := s.ListForInbound(tx, inboundID)
 		current, err := s.ListForInbound(tx, inboundID)

+ 41 - 0
internal/web/service/inbound_masque_test.go

@@ -2,6 +2,7 @@ package service
 
 
 import (
 import (
 	"encoding/json"
 	"encoding/json"
+	"strings"
 	"testing"
 	"testing"
 
 
 	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
 	"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")
 	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":"[email protected]","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,
 // A client added to a MASQUE inbound without a password could never authenticate,
 // and xray-core refuses the whole inbound over one empty pass.
 // and xray-core refuses the whole inbound over one empty pass.
 func TestFillProtocolDefaults_MintsMasquePassword(t *testing.T) {
 func TestFillProtocolDefaults_MintsMasquePassword(t *testing.T) {