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.
This commit is contained in:
MHSanaei
2026-10-11 18:26:45 +02:00
parent 6321297f68
commit 0466e8b0bd
9 changed files with 251 additions and 1 deletions
@@ -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
View File
@@ -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
View File
@@ -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
}
@@ -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
View File
@@ -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
View File
@@ -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)
@@ -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":"sal@example.test","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
View File
@@ -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
View File
@@ -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