diff --git a/internal/web/service/outbound_subscription_test.go b/internal/web/service/outbound_subscription_test.go index 62b4383db..b4f340d06 100644 --- a/internal/web/service/outbound_subscription_test.go +++ b/internal/web/service/outbound_subscription_test.go @@ -717,3 +717,19 @@ func TestOutboundSubscriptionRefreshCapsXMCMasks(t *testing.T) { t.Fatalf("kept %d outbounds, tags by address = %v", len(tags), byAddress) } } + +// The core's XHTTP dialer panics on a downloadSettings block without an address or on another transport. +func TestOutboundSubscriptionRefreshDropsXhttpDownloadSettingsTheCorePanicsOn(t *testing.T) { + setupSettingTestDB(t) + const server = "vless://00000000-0000-4000-8000-000000000000@%s:443?security=tls&type=xhttp&extra=%s#%s\n" + subID := serveOutboundSubscription(t, "p-", func(int) string { + return fmt.Sprintf(server, "1.1.1.1", url.QueryEscape(`{"downloadSettings":{}}`), "Empty") + + fmt.Sprintf(server, "8.8.8.8", url.QueryEscape(`{"downloadSettings":{"address":"8.8.4.4","port":443,"network":"tcp"}}`), "Tcp") + + fmt.Sprintf(server, "9.9.9.9", url.QueryEscape(`{"downloadSettings":{"address":"9.9.9.10","port":443,"network":"xhttp"}}`), "Complete") + }) + + want := map[string]string{"9.9.9.9": "p-complete"} + if _, got := refreshOutboundTags(t, subID); !maps.Equal(got, want) { + t.Fatalf("tags by address = %v, want %v", got, want) + } +} diff --git a/internal/xray/api.go b/internal/xray/api.go index a8d00abc5..418fdabea 100644 --- a/internal/xray/api.go +++ b/internal/xray/api.go @@ -23,6 +23,7 @@ import ( "github.com/mhsanaei/3x-ui/v3/internal/util/common" wgutil "github.com/mhsanaei/3x-ui/v3/internal/util/wireguard" + "github.com/xtls/xray-core/app/proxyman" "github.com/xtls/xray-core/app/proxyman/command" "github.com/xtls/xray-core/app/router" routerService "github.com/xtls/xray-core/app/router/command" @@ -42,6 +43,7 @@ import ( vlessOutbound "github.com/xtls/xray-core/proxy/vless/outbound" "github.com/xtls/xray-core/proxy/vmess" wireguard "github.com/xtls/xray-core/proxy/wireguard" + "github.com/xtls/xray-core/transport/internet/splithttp" "google.golang.org/grpc" "google.golang.org/grpc/codes" "google.golang.org/grpc/credentials/insecure" @@ -221,9 +223,44 @@ func ValidateOutboundConfig(outbound []byte) (err error) { if err != nil { return err } + if err := validateXhttpDownloadSettings(built.SenderSettings); err != nil { + return err + } return validateHandlerSettings(built.ProxySettings) } +// validateXhttpDownloadSettings refuses what conf.Build() lets through but the core's XHTTP +// dialer panics on at every dial: a downloadSettings block without an address or not on xhttp. +func validateXhttpDownloadSettings(settings *serial.TypedMessage) error { + if settings == nil { + return nil + } + instance, err := settings.GetInstance() + if err != nil { + return nil + } + sender, ok := instance.(*proxyman.SenderConfig) + if !ok { + return nil + } + transport, err := sender.StreamSettings.GetEffectiveTransportSettings() + if err != nil { + return nil + } + xhttp, ok := transport.(*splithttp.Config) + if !ok || xhttp.DownloadSettings == nil { + return nil + } + if xhttp.DownloadSettings.Address == nil { + return common.NewError("xhttp downloadSettings has no address") + } + transport, _ = xhttp.DownloadSettings.GetEffectiveTransportSettings() + if _, ok := transport.(*splithttp.Config); !ok { + return common.NewError("xhttp downloadSettings network is not xhttp") + } + return nil +} + // validateHandlerSettings refuses what conf.Build() lets through but the core parses only // in the handler constructor, where one failure aborts the whole core start. func validateHandlerSettings(settings *serial.TypedMessage) error { diff --git a/internal/xray/outbound_validation_test.go b/internal/xray/outbound_validation_test.go index d882355c8..3660ab1e7 100644 --- a/internal/xray/outbound_validation_test.go +++ b/internal/xray/outbound_validation_test.go @@ -71,6 +71,42 @@ func TestValidateOutboundConfig_RejectsWireguardRemoteDNSThatIsNotAnIP(t *testin } } +// The core's XHTTP dialer dereferences downloadSettings' address and casts its transport to +// XHTTP on every dial, so a block lacking either panics the whole core; conf.Build() lets it through. +func TestValidateOutboundConfig_RejectsXhttpDownloadSettingsTheDialerPanicsOn(t *testing.T) { + outbound := func(network, xhttpSettings string) []byte { + return []byte(`{ + "protocol": "vless", + "settings": {"address": "1.2.3.4", "port": 443, "id": "b831381d-6324-4d53-ad4f-8cda48b30811", "encryption": "none"}, + "streamSettings": {"network": "` + network + `", "security": "tls", "xhttpSettings": ` + xhttpSettings + `} + }`) + } + for _, rejected := range []string{ + `{"downloadSettings": {}}`, + `{"downloadSettings": {"port": 443, "network": "xhttp"}}`, + `{"downloadSettings": {"address": null, "port": 443, "network": "xhttp"}}`, + `{"downloadSettings": {"address": "5.6.7.8", "port": 443}}`, + `{"downloadSettings": {"address": "5.6.7.8", "port": 443, "network": "tcp"}}`, + `{"downloadSettings": {"address": "5.6.7.8", "port": 443, "method": "ws"}}`, + `{"DOWNLOADSETTINGS": {}}`, + } { + err := ValidateOutboundConfig(outbound("xhttp", rejected)) + if err == nil || !strings.Contains(err.Error(), "downloadSettings") { + t.Errorf("xhttpSettings %s: want a downloadSettings refusal, got %v", rejected, err) + } + } + for _, accepted := range [][]byte{ + outbound("xhttp", `{"path": "/up"}`), + outbound("xhttp", `{"downloadSettings": {"address": "5.6.7.8", "port": 443, "network": "xhttp"}}`), + outbound("xhttp", `{"downloadSettings": {"address": "down.example", "port": 443, "network": "splithttp", "security": "tls", "xhttpSettings": {"path": "/down"}}}`), + outbound("tcp", `{"downloadSettings": {}}`), + } { + if err := ValidateOutboundConfig(accepted); err != nil { + t.Errorf("outbound %s must stay valid, got: %v", accepted, err) + } + } +} + // xray-core 26.10.17 compiles a noise mask's Lua script inside Build (#7124), where the panel would // look beside its own binary; the xray child finds a relative script in its bin folder. func TestValidateOutboundConfig_FindsANoiseScriptBesideTheXrayBinary(t *testing.T) {