From b9e1d3afee838a2c59a21f01aa2315535cc6d0da Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sun, 11 Oct 2026 20:42:23 +0200 Subject: [PATCH] fix(inbounds): refuse a Shadowsocks-2022 server key xray-core cannot parse xray-core parses the inbound-level Shadowsocks-2022 key with ParseKey and refuses one of the wrong size while building the inbound, which keeps the whole core from starting, not only that inbound. Only client keys were checked, so an API caller posting a 32-byte key on aes-128 (or keeping a 16-byte key after switching to aes-256/chacha20) saved fine and took Xray down. AddInbound and UpdateInbound now reject such a key with a message that names the method and the size it needs. The check uses the core's own GetCipherMethod/ParseKey, so a raw non-base64 key the core accepts stays valid. Startup also replaces a stored wrong-size server key: that inbound never worked, so no client holds a working copy of it. --- internal/database/db.go | 34 ++++++++ .../shadowsocks_server_key_migration_test.go | 69 +++++++++++++++++ internal/web/service/client_crud.go | 23 ++++++ internal/web/service/inbound.go | 6 ++ .../service/shadowsocks_server_key_test.go | 77 +++++++++++++++++++ internal/xray/shadowsocks2022.go | 20 +++++ 6 files changed, 229 insertions(+) create mode 100644 internal/database/shadowsocks_server_key_migration_test.go create mode 100644 internal/web/service/shadowsocks_server_key_test.go create mode 100644 internal/xray/shadowsocks2022.go diff --git a/internal/database/db.go b/internal/database/db.go index 4e555ce35..079f1445a 100644 --- a/internal/database/db.go +++ b/internal/database/db.go @@ -181,6 +181,9 @@ func initModels() error { if err := migrateShadowsocksRemovedCiphers(); err != nil { return err } + if err := repairShadowsocks2022ServerKeys(); err != nil { + return err + } if err := migrateVmessRemovedSecurities(); err != nil { return err } @@ -987,6 +990,37 @@ func migrateShadowsocksRemovedCiphers() error { return nil } +// repairShadowsocks2022ServerKeys replaces a stored server key xray-core cannot +// parse; that inbound never worked, as the core refuses to start with it. +func repairShadowsocks2022ServerKeys() error { + var inbounds []model.Inbound + if err := db.Where("protocol = ?", model.Shadowsocks).Find(&inbounds).Error; err != nil { + return err + } + for _, inbound := range inbounds { + var settings map[string]any + if json.Unmarshal([]byte(inbound.Settings), &settings) != nil { + continue + } + method, _ := settings["method"].(string) + n := xray.Shadowsocks2022KeyBytes(method) + if key, _ := settings["password"].(string); n == 0 || xray.ValidShadowsocks2022Key(method, key) { + continue + } + settings["password"] = random.Base64Bytes(n) + newSettings, err := json.MarshalIndent(settings, "", " ") + if err != nil { + return err + } + if err := db.Model(&model.Inbound{}).Where("id = ?", inbound.Id). + Update("settings", string(newSettings)).Error; err != nil { + return err + } + log.Printf("Inbound %q: replaced a %s server key of the wrong size; its share links changed", inbound.Tag, method) + } + return nil +} + // migrateVmessRemovedSecurities rewrites the vmess "none"/"zero" security // values that xray-core v26.7.11 removed to "auto" (what the core now treats // them as), on both the clients column and each vmess inbound's settings. diff --git a/internal/database/shadowsocks_server_key_migration_test.go b/internal/database/shadowsocks_server_key_migration_test.go new file mode 100644 index 000000000..1a6650f1f --- /dev/null +++ b/internal/database/shadowsocks_server_key_migration_test.go @@ -0,0 +1,69 @@ +package database + +import ( + "encoding/base64" + "encoding/json" + "path/filepath" + "testing" + + "github.com/mhsanaei/3x-ui/v3/internal/database/model" +) + +// A stored Shadowsocks-2022 server key of the wrong size keeps xray-core from +// starting at all, so startup must replace it and leave every valid key alone. +func TestRepairShadowsocks2022ServerKeys(t *testing.T) { + dbDir := t.TempDir() + t.Setenv("XUI_DB_FOLDER", dbDir) + if err := InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil { + t.Fatalf("InitDB: %v", err) + } + t.Cleanup(func() { _ = CloseDB() }) + + key16 := base64.StdEncoding.EncodeToString([]byte("0123456789abcdef")) + key32 := base64.StdEncoding.EncodeToString([]byte("0123456789abcdef0123456789abcdef")) + rows := []struct { + tag, settings, wantKey string + wantBytes int + }{ + {"oversized", `{"method":"2022-blake3-aes-128-gcm","password":"` + key32 + `","clients":[]}`, "", 16}, + {"undersized", `{"method":"2022-blake3-chacha20-poly1305","password":"` + key16 + `","clients":[]}`, "", 32}, + {"valid", `{"method":"2022-blake3-aes-256-gcm","password":"` + key32 + `","clients":[]}`, key32, 32}, + {"raw valid", `{"method":"2022-blake3-aes-128-gcm","password":"0123456789abcd-!","clients":[]}`, "0123456789abcd-!", 0}, + {"legacy cipher", `{"method":"aes-256-gcm","password":"short","clients":[]}`, "short", 0}, + } + for i, r := range rows { + ib := model.Inbound{UserId: 1, Port: 31100 + i, Protocol: model.Shadowsocks, Tag: r.tag, Settings: r.settings} + if err := GetDB().Create(&ib).Error; err != nil { + t.Fatalf("create %s: %v", r.tag, err) + } + } + + if err := repairShadowsocks2022ServerKeys(); err != nil { + t.Fatalf("repairShadowsocks2022ServerKeys: %v", err) + } + + for _, r := range rows { + t.Run(r.tag, func(t *testing.T) { + var ib model.Inbound + if err := GetDB().Where("tag = ?", r.tag).First(&ib).Error; err != nil { + t.Fatal(err) + } + var got struct { + Password string `json:"password"` + } + if err := json.Unmarshal([]byte(ib.Settings), &got); err != nil { + t.Fatal(err) + } + if r.wantKey != "" { + if got.Password != r.wantKey { + t.Errorf("key changed to %q, want it kept as %q", got.Password, r.wantKey) + } + return + } + decoded, err := base64.StdEncoding.DecodeString(got.Password) + if err != nil || len(decoded) != r.wantBytes { + t.Errorf("repaired key %q: %d bytes (err %v), want %d", got.Password, len(decoded), err, r.wantBytes) + } + }) + } +} diff --git a/internal/web/service/client_crud.go b/internal/web/service/client_crud.go index e8fe14629..c9e59ed7d 100644 --- a/internal/web/service/client_crud.go +++ b/internal/web/service/client_crud.go @@ -509,6 +509,29 @@ func shadowsocksKeyBytes(method string) int { return 0 } +// validateShadowsocks2022ServerKey refuses a server key xray-core cannot parse: +// the core then fails to build the inbound and does not start at all. +func validateShadowsocks2022ServerKey(protocol model.Protocol, settings string) error { + if protocol != model.Shadowsocks { + return nil + } + var parsed struct { + Method string `json:"method"` + Password string `json:"password"` + } + if json.Unmarshal([]byte(settings), &parsed) != nil { + return nil + } + n := xray.Shadowsocks2022KeyBytes(parsed.Method) + if n == 0 { + return nil + } + if !xray.ValidShadowsocks2022Key(parsed.Method, parsed.Password) { + return common.NewErrorf("shadowsocks inbound password: %s needs a %d-byte key, base64 encoded", parsed.Method, n) + } + return nil +} + // normalizeShadowsocksClientKeys rewrites any Shadowsocks-2022 client password // whose decoded length no longer matches settings.method, which happens after the // inbound method is switched between ciphers of different key sizes (e.g. diff --git a/internal/web/service/inbound.go b/internal/web/service/inbound.go index a84938c3d..cca391618 100644 --- a/internal/web/service/inbound.go +++ b/internal/web/service/inbound.go @@ -1178,6 +1178,9 @@ func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound, boo if err := validateFinalMaskSalamander(inbound.StreamSettings); err != nil { return inbound, false, err } + if err := validateShadowsocks2022ServerKey(inbound.Protocol, inbound.Settings); err != nil { + return inbound, false, err + } s.normalizeMtprotoSecret(inbound) if err := s.normalizeMtprotoXrayPort(inbound, ""); err != nil { return inbound, false, err @@ -1823,6 +1826,9 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound, if err := validateFinalMaskSalamander(inbound.StreamSettings); err != nil { return inbound, false, err } + if err := validateShadowsocks2022ServerKey(inbound.Protocol, inbound.Settings); err != nil { + return inbound, false, err + } s.normalizeMtprotoSecret(inbound) oldInbound, err := s.GetInbound(inbound.Id) diff --git a/internal/web/service/shadowsocks_server_key_test.go b/internal/web/service/shadowsocks_server_key_test.go new file mode 100644 index 000000000..bc0806fe5 --- /dev/null +++ b/internal/web/service/shadowsocks_server_key_test.go @@ -0,0 +1,77 @@ +package service + +import ( + "encoding/base64" + "fmt" + "strings" + "testing" + + "github.com/mhsanaei/3x-ui/v3/internal/database/model" +) + +func ss2022Settings(method, serverKey string) string { + clientKey := base64.StdEncoding.EncodeToString(make([]byte, 16)) + if method != "2022-blake3-aes-128-gcm" { + clientKey = base64.StdEncoding.EncodeToString(make([]byte, 32)) + } + return fmt.Sprintf(`{"method":%q,"password":%q,"network":"tcp,udp","clients":[{"email":"ss-key","password":%q,"enable":true}]}`, + method, serverKey, clientKey) +} + +// xray-core refuses a wrong-size Shadowsocks-2022 server key while building the +// inbound, which keeps the whole core from starting, not just this inbound. +func TestShadowsocks2022ServerKeyRejected(t *testing.T) { + key32 := base64.StdEncoding.EncodeToString(make([]byte, 32)) + key16 := base64.StdEncoding.EncodeToString(make([]byte, 16)) + + t.Run("add", func(t *testing.T) { + setupConflictDB(t) + nodeID, fake := setupNodeRuntime(t) + ib := &model.Inbound{ + UserId: 1, NodeID: &nodeID, Tag: "ss-add", Enable: true, Port: 41801, Protocol: model.Shadowsocks, + Settings: ss2022Settings("2022-blake3-aes-128-gcm", key32), + } + _, _, err := (&InboundService{}).AddInbound(ib) + if err == nil || !strings.Contains(err.Error(), "2022-blake3-aes-128-gcm needs a 16-byte key") { + t.Fatalf("AddInbound with a 32-byte aes-128 key: err = %v", err) + } + if fake.addInbound.Load() != 0 { + t.Fatal("rejected inbound dispatched to the runtime") + } + }) + + t.Run("update", func(t *testing.T) { + setupConflictDB(t) + nodeID, fake := setupNodeRuntime(t) + svc := &InboundService{} + ib := &model.Inbound{ + UserId: 1, NodeID: &nodeID, Tag: "ss-update", Enable: true, Port: 41802, Protocol: model.Shadowsocks, + Settings: ss2022Settings("2022-blake3-aes-128-gcm", key16), + } + if _, _, err := svc.AddInbound(ib); err != nil { + t.Fatalf("AddInbound with a valid key: %v", err) + } + update := *ib + update.Settings = ss2022Settings("2022-blake3-aes-256-gcm", key16) + _, _, err := svc.UpdateInbound(&update) + if err == nil || !strings.Contains(err.Error(), "2022-blake3-aes-256-gcm needs a 32-byte key") { + t.Fatalf("UpdateInbound keeping a 16-byte key on aes-256: err = %v", err) + } + if fake.updateInbound.Load() != 0 { + t.Fatal("rejected update dispatched to the runtime") + } + }) + + // The core reads a non-base64 key as its raw bytes, so this one is valid. + t.Run("raw key the core accepts", func(t *testing.T) { + setupConflictDB(t) + nodeID, _ := setupNodeRuntime(t) + ib := &model.Inbound{ + UserId: 1, NodeID: &nodeID, Tag: "ss-raw", Enable: true, Port: 41803, Protocol: model.Shadowsocks, + Settings: ss2022Settings("2022-blake3-aes-128-gcm", "0123456789abcd-!"), + } + if _, _, err := (&InboundService{}).AddInbound(ib); err != nil { + t.Fatalf("AddInbound with a raw 16-byte key: %v", err) + } + }) +} diff --git a/internal/xray/shadowsocks2022.go b/internal/xray/shadowsocks2022.go new file mode 100644 index 000000000..181fb02ed --- /dev/null +++ b/internal/xray/shadowsocks2022.go @@ -0,0 +1,20 @@ +package xray + +import "github.com/xtls/xray-core/proxy/shadowsocks_2022" + +// Shadowsocks2022KeyBytes is the key size a Shadowsocks-2022 method takes, or 0 +// for any other method, whose password is free-form. +func Shadowsocks2022KeyBytes(method string) int { + cipher, err := shadowsocks_2022.GetCipherMethod(method) + if err != nil { + return 0 + } + return cipher.KeySaltLength +} + +// ValidShadowsocks2022Key reads key the way the core does: base64, else the raw +// string, and either way exactly the method's key size. +func ValidShadowsocks2022Key(method, key string) bool { + _, err := shadowsocks_2022.ParseKey(key, Shadowsocks2022KeyBytes(method)) + return err == nil +}