Browse Source

fix(finalmask): refuse a salamander password xray-core cannot open

xray-core needs a salamander (and gecko) password of at least 4 bytes
(smPSKMinLen), but checks it only when a socket opens: xray run -test
reports Configuration OK. On an inbound the listener then fails and Xray
does not start at all, taking every other inbound down; on a client the
dial fails. The panel saved any password, and the finalmask editor
starts a new salamander mask with an empty one.

maskcompat.CheckSalamanderPasswords applies the core's byte rule, pinned
against the core's own ListenPacket, and every path that stores a
finalmask refuses a short password: inbound add and update, hosts, the
subscription's global finalmask, and the inbounds and outbounds of the
Xray template. The editor flags it inline as it is typed.
MHSanaei 1 day ago
parent
commit
0466e8b0bd

+ 19 - 1
frontend/src/lib/xray/forms/transport/FinalMaskForm.tsx

@@ -88,6 +88,20 @@ function validateGeckoPacketSize(_rule: unknown, value: unknown): Promise<void>
   );
 }
 
+// xray-core's salamander smPSKMinLen counts bytes; a shorter password loads, then the listener
+// (stopping Xray) or the dial fails, so the backend refuses it at save too.
+const SALAMANDER_MIN_PSK_BYTES = 4;
+
+function validateSalamanderPassword(_rule: unknown, value: unknown): Promise<void> {
+  const bytes = new TextEncoder().encode(typeof value === 'string' ? value : '').length;
+  if (bytes >= SALAMANDER_MIN_PSK_BYTES) return Promise.resolve();
+  return Promise.reject(
+    new Error(
+      `Use at least ${SALAMANDER_MIN_PSK_BYTES} bytes; xray-core refuses a shorter password`,
+    ),
+  );
+}
+
 function asPath(name: NamePath): (string | number)[] {
   return Array.isArray(name) ? [...name] : [name];
 }
@@ -1198,7 +1212,11 @@ function SalamanderUdpMaskSettings({
 
       <Form.Item label="Password">
         <Space.Compact block>
-          <Form.Item name={[fieldName, 'settings', 'password']} noStyle>
+          <Form.Item
+            name={[fieldName, 'settings', 'password']}
+            noStyle
+            rules={[{ validator: validateSalamanderPassword }]}
+          >
             <Input placeholder="Obfuscation password" style={{ width: 'calc(100% - 32px)' }} />
           </Form.Item>
           <Button

+ 22 - 0
frontend/src/test/finalmask-form.test.tsx

@@ -1,4 +1,5 @@
 import { describe, expect, it } from 'vitest';
+import { fireEvent, screen, waitFor } from '@testing-library/react';
 
 import { FinalMaskField } from '@/lib/xray/forms/fields';
 
@@ -27,3 +28,24 @@ describe('FinalMaskForm item types', () => {
     expect(listSelectOptions('finalmask_udp_0_settings_noise_0_type')).toContain('Expression');
   });
 });
+
+describe('FinalMaskForm salamander password', () => {
+  // xray-core loads a salamander password under 4 bytes and refuses it only when the socket
+  // opens: on an inbound that stops Xray. It counts bytes, so "a€" (4 bytes) is long enough.
+  it('flags a password under 4 bytes and accepts a 4-byte one', async () => {
+    renderWithProviders(
+      <FinalMaskField
+        network="hysteria"
+        protocol="hysteria"
+        value={{ tcp: [], udp: [{ type: 'salamander', settings: { password: 'long-enough' } }] }}
+      />,
+    );
+    const input = document.getElementById('finalmask_udp_0_settings_password') as HTMLInputElement;
+
+    fireEvent.change(input, { target: { value: 'abc' } });
+    expect(await screen.findByText(/at least 4 bytes/)).toBeTruthy();
+
+    fireEvent.change(input, { target: { value: 'a€' } });
+    await waitFor(() => expect(screen.queryByText(/at least 4 bytes/)).toBeNull());
+  });
+});

+ 27 - 0
internal/util/maskcompat/salamander.go

@@ -0,0 +1,27 @@
+package maskcompat
+
+import (
+	"fmt"
+	"strings"
+)
+
+// SalamanderMinPSK is xray-core's smPSKMinLen, in bytes; the core enforces it only when a socket
+// opens, so a shorter password loads fine and then stops the listener or fails every dial.
+const SalamanderMinPSK = 4
+
+// CheckSalamanderPasswords refuses a salamander (or gecko) UDP mask the core would not open.
+func CheckSalamanderPasswords(finalmask any) error {
+	fm, _ := finalmask.(map[string]any)
+	masks, _ := fm["udp"].([]any)
+	for _, entry := range masks {
+		mask, _ := entry.(map[string]any)
+		if maskType, _ := mask["type"].(string); !strings.EqualFold(maskType, "salamander") {
+			continue
+		}
+		settings, _ := mask["settings"].(map[string]any)
+		if password, _ := settings["password"].(string); len(password) < SalamanderMinPSK {
+			return fmt.Errorf("salamander finalmask password must be at least %d bytes; xray-core refuses a shorter one", SalamanderMinPSK)
+		}
+	}
+	return nil
+}

+ 53 - 0
internal/util/maskcompat/salamander_test.go

@@ -0,0 +1,53 @@
+package maskcompat
+
+import (
+	"context"
+	"encoding/json"
+	"testing"
+
+	xnet "github.com/xtls/xray-core/common/net"
+	"github.com/xtls/xray-core/infra/conf"
+	"github.com/xtls/xray-core/transport/internet"
+)
+
+// coreListensSalamander opens a listener through the finalmask the way an inbound does; the core
+// checks the salamander password only here, never when it loads the config.
+func coreListensSalamander(t *testing.T, password string) bool {
+	t.Helper()
+	raw, _ := json.Marshal(map[string]any{"finalmask": map[string]any{"udp": []any{
+		map[string]any{"type": "salamander", "settings": map[string]any{"password": password}},
+	}}})
+	var sc conf.StreamConfig
+	if err := json.Unmarshal(raw, &sc); err != nil {
+		t.Fatalf("decode stream: %v", err)
+	}
+	built, err := sc.Build()
+	if err != nil {
+		t.Fatalf("the core refuses to load the stream: %v", err)
+	}
+	mss, err := internet.ToMemoryStreamConfig(built)
+	if err != nil {
+		t.Fatalf("memory stream: %v", err)
+	}
+	conn, err := mss.FinalMask.ListenPacket(context.Background(), &xnet.UDPAddr{IP: []byte{127, 0, 0, 1}})
+	if err != nil {
+		return false
+	}
+	_ = conn.Close()
+	return true
+}
+
+// The panel must refuse exactly the passwords the core refuses: it counts bytes, not characters.
+func TestCheckSalamanderPasswordsMatchesTheCore(t *testing.T) {
+	for _, password := range []string{"", "abc", "€", "abcd", "a€", "long-enough"} {
+		t.Run(password, func(t *testing.T) {
+			finalmask := map[string]any{"udp": []any{
+				map[string]any{"type": "salamander", "settings": map[string]any{"password": password}},
+			}}
+			panelAccepts := CheckSalamanderPasswords(finalmask) == nil
+			if coreAccepts := coreListensSalamander(t, password); panelAccepts != coreAccepts {
+				t.Fatalf("password %q: panel accepts %v, core listens %v", password, panelAccepts, coreAccepts)
+			}
+		})
+	}
+}

+ 6 - 0
internal/web/service/host.go

@@ -229,6 +229,9 @@ func (s *HostService) AddHostGroup(req *entity.HostGroup) ([]*model.Host, error)
 	if groupId == "" {
 		groupId = random.NumLower(16)
 	}
+	if err := validateFinalMaskJSONSalamander(req.FinalMask); err != nil {
+		return nil, err
+	}
 	created := buildHostRows(groupId, req)
 
 	err := database.GetDB().Transaction(func(tx *gorm.DB) error {
@@ -247,6 +250,9 @@ func (s *HostService) AddHostGroup(req *entity.HostGroup) ([]*model.Host, error)
 }
 
 func (s *HostService) UpdateHostGroup(groupId string, req *entity.HostGroup) ([]*model.Host, error) {
+	if err := validateFinalMaskJSONSalamander(req.FinalMask); err != nil {
+		return nil, err
+	}
 	created := buildHostRows(groupId, req)
 
 	err := database.GetDB().Transaction(func(tx *gorm.DB) error {

+ 25 - 0
internal/web/service/inbound.go

@@ -995,6 +995,25 @@ func validateFinalMaskXmcProfiles(streamSettings string) error {
 	return common.NewError("XMC finalmask requires at least one complete Minecraft profile — each needs a username (3-16 of A-Z a-z 0-9 _), a UUID, and both texture fields from Mojang's session server (XTLS/Xray-core#6487). Complete the profiles or remove the XMC mask.")
 }
 
+// validateFinalMaskSalamander refuses at save a salamander password the core loads and then refuses
+// when the listener opens, which stops every other inbound with it.
+func validateFinalMaskSalamander(streamSettings string) error {
+	var stream map[string]any
+	if json.Unmarshal([]byte(streamSettings), &stream) != nil {
+		return nil
+	}
+	return maskcompat.CheckSalamanderPasswords(stream["finalmask"])
+}
+
+// validateFinalMaskJSONSalamander checks a stored whole finalmask: a host's, or the subscription's.
+func validateFinalMaskJSONSalamander(finalmask string) error {
+	var fm map[string]any
+	if json.Unmarshal([]byte(finalmask), &fm) != nil {
+		return nil
+	}
+	return maskcompat.CheckSalamanderPasswords(fm)
+}
+
 // normalizeMtprotoSecret rebuilds every mtproto client's FakeTLS secret so it is
 // always valid before the row is persisted, and drops the vestigial inbound-level
 // secret and adTag: MTProto is multi-client, so mtg and every share link read
@@ -1156,6 +1175,9 @@ func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound, boo
 	if err := validateFinalMaskXmcProfiles(inbound.StreamSettings); err != nil {
 		return inbound, false, err
 	}
+	if err := validateFinalMaskSalamander(inbound.StreamSettings); err != nil {
+		return inbound, false, err
+	}
 	s.normalizeMtprotoSecret(inbound)
 	if err := s.normalizeMtprotoXrayPort(inbound, ""); err != nil {
 		return inbound, false, err
@@ -1798,6 +1820,9 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound,
 	if err := validateFinalMaskXmcProfiles(inbound.StreamSettings); err != nil {
 		return inbound, false, err
 	}
+	if err := validateFinalMaskSalamander(inbound.StreamSettings); err != nil {
+		return inbound, false, err
+	}
 	s.normalizeMtprotoSecret(inbound)
 
 	oldInbound, err := s.GetInbound(inbound.Id)

+ 83 - 0
internal/web/service/inbound_finalmask_salamander_test.go

@@ -0,0 +1,83 @@
+package service
+
+import (
+	"strings"
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+	"github.com/mhsanaei/3x-ui/v3/internal/web/entity"
+)
+
+func hysteriaSalamanderStream(password string) string {
+	return `{"network":"hysteria","security":"tls","hysteriaSettings":{"version":2},
+		"tlsSettings":{"alpn":["h3"],"certificates":[{"certificateFile":"/etc/ssl/h.crt","keyFile":"/etc/ssl/h.key"}]},
+		"finalmask":{"udp":[{"type":"salamander","settings":{"password":"` + password + `"}}]}}`
+}
+
+func hysteriaInbound(port int, stream string) *model.Inbound {
+	return &model.Inbound{
+		Enable: true, Listen: "127.0.0.1", Port: port, Protocol: model.Hysteria,
+		Settings:       `{"version":2,"clients":[{"email":"[email protected]","auth":"a","enable":true}]}`,
+		StreamSettings: stream,
+	}
+}
+
+// xray-core loads a salamander mask whose password is under 4 bytes and refuses it only when the
+// socket opens: on an inbound that stops every listener, so each save path must refuse it.
+func TestSavePathsRefuseShortSalamanderPassword(t *testing.T) {
+	const want = "salamander finalmask password must be at least 4 bytes"
+	tests := []struct {
+		name string
+		save func(t *testing.T) error
+	}{
+		{"add inbound", func(t *testing.T) error {
+			_, _, err := (&InboundService{}).AddInbound(hysteriaInbound(18443, hysteriaSalamanderStream("")))
+			return err
+		}},
+		{"update inbound", func(t *testing.T) error {
+			in, _, err := (&InboundService{}).AddInbound(hysteriaInbound(18444, hysteriaSalamanderStream("long-enough")))
+			if err != nil {
+				t.Fatalf("AddInbound: %v", err)
+			}
+			in.StreamSettings = hysteriaSalamanderStream("abc")
+			_, _, err = (&InboundService{}).UpdateInbound(in)
+			return err
+		}},
+		{"add host", func(t *testing.T) error {
+			ib := mkInbound(t, 18445, model.VLESS, `{"clients":[]}`)
+			_, err := (&HostService{}).AddHostGroup(&entity.HostGroup{
+				InboundIds: []int{ib.Id}, Remark: "h", Port: 443,
+				FinalMask: `{"udp":[{"type":"salamander","settings":{"password":"€"}}]}`,
+			})
+			return err
+		}},
+		{"subscription JSON finalmask", func(t *testing.T) error {
+			s := &SettingService{}
+			all, err := s.GetAllSetting()
+			if err != nil {
+				t.Fatalf("GetAllSetting: %v", err)
+			}
+			all.SubJsonFinalMask = `{"udp":[{"type":"salamander","settings":{"password":"ab"}}]}`
+			return s.UpdateAllSetting(all, SecretClears{})
+		}},
+		{"xray template inbound", func(t *testing.T) error {
+			return (&XraySettingService{}).SaveXraySetting(`{"inbounds":[{"tag":"hy","port":18446,"protocol":"hysteria",
+				"settings":{"version":2,"clients":[]},"streamSettings":` + hysteriaSalamanderStream("") + `}],
+				"outbounds":[{"protocol":"freedom","tag":"direct"}]}`)
+		}},
+		{"xray template outbound", func(t *testing.T) error {
+			return (&XraySettingService{}).SaveXraySetting(`{"outbounds":[{"protocol":"freedom","tag":"direct"},
+				{"protocol":"hysteria","tag":"hy-out","settings":{"version":2,"address":"hy.example.com","port":443},
+				"streamSettings":{"network":"hysteria","security":"tls","hysteriaSettings":{"version":2,"auth":"a"},
+					"finalmask":{"udp":[{"type":"salamander","settings":{"password":"abc"}}]}}}]}`)
+		}},
+	}
+	for _, tc := range tests {
+		t.Run(tc.name, func(t *testing.T) {
+			setupBulkDB(t)
+			if err := tc.save(t); err == nil || !strings.Contains(err.Error(), want) {
+				t.Fatalf("save err = %v, want one containing %q", err, want)
+			}
+		})
+	}
+}

+ 3 - 0
internal/web/service/setting.go

@@ -1665,6 +1665,9 @@ func (s *SettingService) UpdateAllSetting(allSetting *entity.AllSetting, clears
 	if err := validateSubJsonDnsSetting(allSetting); err != nil {
 		return err
 	}
+	if err := validateFinalMaskJSONSalamander(allSetting.SubJsonFinalMask); err != nil {
+		return err
+	}
 	if err := allSetting.CheckValid(); err != nil {
 		return err
 	}

+ 13 - 0
internal/web/service/xray_setting.go

@@ -53,6 +53,11 @@ func (s *XraySettingService) CheckXrayConfig(XrayTemplateConfig string) error {
 	if err != nil {
 		return common.NewError("xray template config invalid:", err)
 	}
+	for _, inbound := range xrayConfig.InboundConfigs {
+		if err := validateFinalMaskSalamander(string(inbound.StreamSettings)); err != nil {
+			return common.NewError("xray template inbound \""+inbound.Tag+"\":", err)
+		}
+	}
 	if len(xrayConfig.OutboundConfigs) > 0 {
 		var outbounds []json.RawMessage
 		if err := json.Unmarshal(xrayConfig.OutboundConfigs, &outbounds); err != nil {
@@ -77,6 +82,14 @@ func (s *XraySettingService) CheckXrayConfig(XrayTemplateConfig string) error {
 				}
 				continue
 			}
+			var probe struct {
+				Tag            string          `json:"tag"`
+				StreamSettings json.RawMessage `json:"streamSettings"`
+			}
+			_ = json.Unmarshal(outbound, &probe)
+			if err := validateFinalMaskSalamander(string(probe.StreamSettings)); err != nil {
+				return common.NewError("xray template outbound \""+probe.Tag+"\":", err)
+			}
 			if err := xray.ValidateOutboundConfig(outbound); err != nil {
 				if shouldSkipLegacyUnencryptedOutboundRejection(coreVersion, err) {
 					continue