diff --git a/internal/tuic/server.go b/internal/tuic/server.go index ab4c4841c..1245b335c 100644 --- a/internal/tuic/server.go +++ b/internal/tuic/server.go @@ -19,6 +19,7 @@ import ( "github.com/xtls/xray-core/transport/internet/hysteria/congestion/bbr" "github.com/mhsanaei/3x-ui/v3/internal/logger" + "github.com/mhsanaei/3x-ui/v3/internal/xray" ) // Server is an in-process native Go TUIC v5 server terminating QUIC @@ -1089,6 +1090,12 @@ func loadCertificate(certInput, keyInput string) (tls.Certificate, error) { if strings.Contains(certInput, "-----BEGIN CERTIFICATE-----") { return tls.X509KeyPair([]byte(certInput), []byte(keyInput)) } + // A pushed inbound names these paths, and both are read whole. + for _, path := range []string{certInput, keyInput} { + if err := xray.CheckCertFile(path); err != nil { + return tls.Certificate{}, err + } + } return tls.LoadX509KeyPair(certInput, keyInput) } diff --git a/internal/tuic/types.go b/internal/tuic/types.go index f06904e3c..fba294046 100644 --- a/internal/tuic/types.go +++ b/internal/tuic/types.go @@ -11,6 +11,7 @@ import ( "strings" "github.com/mhsanaei/3x-ui/v3/internal/database/model" + "github.com/mhsanaei/3x-ui/v3/internal/xray" ) type TuicServerSettings struct { @@ -73,7 +74,7 @@ func (inst Instance) StructuralFingerprint() string { func fingerprintMaterial(value string) string { material := []byte(value) - if value != "" && !strings.Contains(value, "-----BEGIN ") { + if value != "" && !strings.Contains(value, "-----BEGIN ") && xray.CheckCertFile(value) == nil { if file, err := os.ReadFile(value); err == nil { material = file } diff --git a/internal/tuic/types_test.go b/internal/tuic/types_test.go index a3ae70015..0d29f15de 100644 --- a/internal/tuic/types_test.go +++ b/internal/tuic/types_test.go @@ -3,6 +3,7 @@ package tuic import ( "os" "path/filepath" + "strings" "testing" "github.com/mhsanaei/3x-ui/v3/internal/database/model" @@ -158,3 +159,23 @@ func TestBindTo(t *testing.T) { }) } } + +// A pushed TUIC inbound names any path on this host, and both readers take the file +// whole, so a device or an oversized file is refused before either opens it. +func TestCertificateReadersRefuseWhatIsNoBoundedRegularFile(t *testing.T) { + big := filepath.Join(t.TempDir(), "big.pem") + if err := os.WriteFile(big, make([]byte, 1<<20+1), 0o600); err != nil { + t.Fatal(err) + } + if _, err := loadCertificate(big, big); err == nil || !strings.Contains(err.Error(), "regular file") { + t.Fatalf("loadCertificate(oversized) = %v, want the path refused before the read", err) + } + inst := Instance{Certificate: big} + before := inst.StructuralFingerprint() + if err := os.WriteFile(big, append(make([]byte, 1<<20), 1, 2), 0o600); err != nil { + t.Fatal(err) + } + if inst.StructuralFingerprint() != before { + t.Fatal("the oversized certificate file was read into the fingerprint") + } +} diff --git a/internal/web/service/inbound.go b/internal/web/service/inbound.go index 3bfdbc9d8..27d10dbee 100644 --- a/internal/web/service/inbound.go +++ b/internal/web/service/inbound.go @@ -713,6 +713,18 @@ func canonicalizeStreamNetworkKey(streamSettings string) string { return string(out) } +// validateInboundBuildInput refuses what xray-core's in-process build would expand without +// bound; certificate paths are checked only where this host is the one opening them. +func validateInboundBuildInput(inbound *model.Inbound) error { + if err := xray.CheckStreamBuildCost([]byte(inbound.StreamSettings)); err != nil { + return err + } + if inbound.NodeID != nil { + return nil + } + return xray.CheckConfigCertFiles([]byte(inbound.StreamSettings)) +} + // validateInboundTLSCertificates rejects incomplete TLS credentials before a save // can restart Xray. File paths belong to the node, so only presence is checked. func validateInboundTLSCertificates(streamSettings string) error { @@ -1290,6 +1302,9 @@ func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound, boo return inbound, false, err } } + if err := validateInboundBuildInput(inbound); err != nil { + return inbound, false, err + } if err := validateFinalMaskRealityCombo(inbound.StreamSettings); err != nil { return inbound, false, err } @@ -2007,6 +2022,9 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound, } inbound.SubSortIndex = normalizeSubSortIndex(inbound.SubSortIndex) + if err := validateInboundBuildInput(inbound); err != nil { + return inbound, false, err + } // Grandfather a row that was already stored incomplete so it stays editable; // only a save that breaks a previously valid TLS block is refused. if !s.FromNodeSync { diff --git a/internal/web/service/inbound_tls_test.go b/internal/web/service/inbound_tls_test.go index 105ca2400..8c1a5f947 100644 --- a/internal/web/service/inbound_tls_test.go +++ b/internal/web/service/inbound_tls_test.go @@ -1,6 +1,7 @@ package service import ( + "path/filepath" "reflect" "strings" "testing" @@ -183,3 +184,34 @@ func TestUpdateInboundAllowsUntouchedLegacyTLSCertificates(t *testing.T) { }) } } + +// A push names any path on this host, and xray-core reads it whole in the panel process +// (a device never ends, /dev/zero never stops growing), so it is refused before storing. +func TestAddInboundRefusesACertificatePathThatIsNoRegularFile(t *testing.T) { + setupConflictDB(t) + mgr := useTestRuntimeManager(t) + fake := &fakeNodeRuntime{} + mgr.SetLocalRuntimeOverride(fake) + + dir := filepath.ToSlash(t.TempDir()) + inbound := &model.Inbound{ + Tag: "tls-device-44311", + Enable: true, + Listen: "0.0.0.0", + Port: 44311, + Protocol: model.VLESS, + StreamSettings: `{"network":"tcp","security":"tls","tlsSettings":{"certificates":[{"certificateFile":"` + dir + `","KeyFile":"` + dir + `"}]}}`, + Settings: `{"clients":[]}`, + } + _, _, err := (&InboundService{FromNodeSync: true}).AddInbound(inbound) + if err == nil || !strings.Contains(err.Error(), "regular file") { + t.Fatalf("AddInbound = %v, want the certificate path refused", err) + } + var count int64 + if err := database.GetDB().Model(&model.Inbound{}).Count(&count).Error; err != nil || count != 0 { + t.Fatalf("refused push stored %d row(s) (%v)", count, err) + } + if fake.addInbound.Load() != 0 { + t.Fatal("refused push reached the runtime") + } +} diff --git a/internal/web/service/outbound_subscription.go b/internal/web/service/outbound_subscription.go index 230b54c5d..99973ca1a 100644 --- a/internal/web/service/outbound_subscription.go +++ b/internal/web/service/outbound_subscription.go @@ -486,9 +486,14 @@ func (s *OutboundSubscriptionService) fetchOutbounds(sub *model.OutboundSubscrip // persisted (filtered) list, so positions must be counted in that same list. var dropped []string ownPorts := xrayOwnPorts() + xmcBudget := maxSubscriptionXMCMasks keptLinks, keptIdentities := parsed[:0], identities[:0] for i, ob := range parsed { - if reason := internalDialTarget(map[string]any(ob), sub.AllowPrivate, ownPorts); reason != "" { + reason := internalDialTarget(map[string]any(ob), sub.AllowPrivate, ownPorts) + if reason == "" && !spendXMCBudget(map[string]any(ob), &xmcBudget) { + reason = fmt.Sprintf("the subscription uses more than %d xmc masks", maxSubscriptionXMCMasks) + } + if reason != "" { logger.Warningf("outbound sub %d: dropping outbound %.64q: %s", sub.Id, ob["tag"], reason) dropped = append(dropped, fmt.Sprintf("%.64s: %s", ob["tag"], reason)) continue @@ -531,6 +536,27 @@ func (s *OutboundSubscriptionService) fetchOutbounds(sub *model.OutboundSubscrip return fetched, nil } +// maxSubscriptionXMCMasks bounds the RSA keys one subscription costs the core per build; +// the masks can be spread over links, so a per-stream cap alone does not bound it. +const maxSubscriptionXMCMasks = 64 + +func spendXMCBudget(outbound any, budget *int) bool { + ob, ok := outbound.(map[string]any) + if !ok { + return true + } + stream, err := json.Marshal(ob["streamSettings"]) + if err != nil { + return true + } + n := xray.CountXMCMasks(stream) + if n > *budget { + return false + } + *budget -= n + return true +} + // withoutRequestURL keeps only the host of the URL net/url and net/http quote in their // errors: a subscription URL is a credential, and these errors are logged and stored. func withoutRequestURL(err error) error { @@ -652,6 +678,7 @@ func (s *OutboundSubscriptionService) activeOutboundsSplit() (prepend []any, app } ownPorts := xrayOwnPorts() for _, sub := range subs { + xmcBudget := maxSubscriptionXMCMasks if strings.TrimSpace(sub.LastFetchedOutbounds) == "" { continue } @@ -663,6 +690,9 @@ func (s *OutboundSubscriptionService) activeOutboundsSplit() (prepend []any, app // Rows stored before the fetch refused these, or while allowPrivate was on. arr = slices.DeleteFunc(arr, func(ob any) bool { reason := internalDialTarget(ob, sub.AllowPrivate, ownPorts) + if reason == "" && !spendXMCBudget(ob, &xmcBudget) { + reason = "over the subscription's xmc mask budget" + } if reason != "" { logger.Warningf("outbound sub %d: dropping a stored outbound: %s", sub.Id, reason) } diff --git a/internal/web/service/outbound_subscription_test.go b/internal/web/service/outbound_subscription_test.go index 0909fed0c..62b4383db 100644 --- a/internal/web/service/outbound_subscription_test.go +++ b/internal/web/service/outbound_subscription_test.go @@ -695,3 +695,25 @@ func TestOutboundSubscriptionCreateKeepsItDisabled(t *testing.T) { t.Fatalf("stored enabled = %v (%v), want the subscription created disabled", stored.Enabled, err) } } + +// The core derives an RSA key per xmc mask on every build, so the masks a document may +// spread over its links are budgeted per subscription, before anything is built. +func TestOutboundSubscriptionRefreshCapsXMCMasks(t *testing.T) { + setupSettingTestDB(t) + const xmc = `{"type":"xmc","settings":{"password":"p","profiles":[{"username":"abc","uuid":"00000000-0000-0000-0000-000000000000","texturesValue":"a","texturesSignature":"a"}]}}` + server := func(address string, masks ...string) string { + return "vless://00000000-0000-4000-8000-000000000000@" + address + ":443?security=tls&type=tcp&fm=" + + url.QueryEscape(`{"tcp":[`+strings.Join(masks, ",")+`]}`) + "#" + address + "\n" + } + four := []string{xmc, xmc, xmc, xmc} + subID := serveOutboundSubscription(t, "p-", func(int) string { + return server("1.1.1.1", slices.Repeat([]string{xmc}, 400)...) + + strings.Repeat(server("1.1.1.3", four...), maxSubscriptionXMCMasks/4) + + server("1.1.1.4", xmc) + + server("1.1.1.5", `{"type":"sudoku","settings":{"password":"p"}}`) + }) + tags, byAddress := refreshOutboundTags(t, subID) + if _, plain := byAddress["1.1.1.5"]; len(tags) != maxSubscriptionXMCMasks/4+1 || len(byAddress) != 2 || !plain { + t.Fatalf("kept %d outbounds, tags by address = %v", len(tags), byAddress) + } +} diff --git a/internal/web/service/server.go b/internal/web/service/server.go index 6a6a15b93..cab19c6df 100644 --- a/internal/web/service/server.go +++ b/internal/web/service/server.go @@ -2687,6 +2687,9 @@ func (s *ServerService) GetCertHash(certFile string, certContent string) ([]stri if !ok { return nil, common.NewError("certificate file is not referenced by any inbound or panel setting") } + if err := xray.CheckCertFile(known); err != nil { + return nil, err + } b, err := os.ReadFile(known) if err != nil { return nil, err @@ -2760,6 +2763,10 @@ func (s *ServerService) knownCertFiles() []string { } if inbounds, err := s.inboundService.GetAllInbounds(); err == nil { for _, inbound := range inbounds { + // A node row's path names a file on the node's host, not on this one. + if inbound.NodeID != nil { + continue + } files = collectCertFiles(inbound.StreamSettings, files) } } diff --git a/internal/web/service/server_remote_cert_hash_test.go b/internal/web/service/server_remote_cert_hash_test.go index 21f446437..542ac94bc 100644 --- a/internal/web/service/server_remote_cert_hash_test.go +++ b/internal/web/service/server_remote_cert_hash_test.go @@ -6,9 +6,14 @@ import ( "errors" "net/http" "net/http/httptest" + "os" + "path/filepath" + "strconv" "strings" "testing" + "github.com/mhsanaei/3x-ui/v3/internal/database" + "github.com/mhsanaei/3x-ui/v3/internal/database/model" "github.com/mhsanaei/3x-ui/v3/internal/util/netsafe" ) @@ -36,3 +41,22 @@ func TestGetRemoteCertHashGuardsPrivateTargets(t *testing.T) { } }) } + +// A node reports its inbounds' certificate paths; they name files on the node, so the +// master must not read its own file of that name when the admin asks for a pin. +func TestGetCertHashIgnoresPathsANodeReports(t *testing.T) { + setupSettingTestDB(t) + certFile := filepath.Join(t.TempDir(), "cert.pem") + if err := os.WriteFile(certFile, []byte("not a certificate"), 0o600); err != nil { + t.Fatalf("write cert: %v", err) + } + nodeID := 1 + stream := `{"security":"tls","tlsSettings":{"certificates":[{"certificateFile":` + strconv.Quote(certFile) + `}]}}` + if err := database.GetDB().Create(&model.Inbound{Tag: "node-in", Port: 44312, Protocol: model.VLESS, NodeID: &nodeID, StreamSettings: stream}).Error; err != nil { + t.Fatalf("seed node inbound: %v", err) + } + _, err := (&ServerService{}).GetCertHash(certFile, "") + if err == nil || !strings.Contains(err.Error(), "not referenced") { + t.Fatalf("GetCertHash(node-reported path) = %v, want it treated as unknown", err) + } +} diff --git a/internal/xray/api.go b/internal/xray/api.go index 906b348f9..2a38b137e 100644 --- a/internal/xray/api.go +++ b/internal/xray/api.go @@ -150,6 +150,9 @@ func (x *XrayAPI) AddInbound(inbound []byte) error { logger.Debug("Failed to unmarshal inbound:", err) return err } + if err := checkUntrustedBuildInput(conf.StreamSetting, inbound); err != nil { + return err + } localizeNoiseScripts(conf.StreamSetting) config, err := conf.Build() if err != nil { @@ -196,6 +199,9 @@ func ValidateOutboundConfig(outbound []byte) (err error) { if err := json.Unmarshal(outbound, detour); err != nil { return err } + if err := checkUntrustedBuildInput(detour.StreamSetting, outbound); err != nil { + return err + } localizeNoiseScripts(detour.StreamSetting) built, err := detour.Build() if err != nil { @@ -314,6 +320,9 @@ func (x *XrayAPI) AddOutbound(outbound []byte) error { logger.Debug("Failed to unmarshal outbound:", err) return err } + if err := checkUntrustedBuildInput(conf.StreamSetting, outbound); err != nil { + return err + } localizeNoiseScripts(conf.StreamSetting) config, err := conf.Build() if err != nil { diff --git a/internal/xray/build_cost.go b/internal/xray/build_cost.go new file mode 100644 index 000000000..1ff35b810 --- /dev/null +++ b/internal/xray/build_cost.go @@ -0,0 +1,183 @@ +package xray + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + + "github.com/mhsanaei/3x-ui/v3/internal/util/common" + + "github.com/xtls/xray-core/common/platform" + "github.com/xtls/xray-core/infra/conf" +) + +const ( + // A session ID travels in a path segment, a header, a cookie or a query value. + maxXHTTPSessionIDLength = 1024 + maxUDPHopPorts = 65536 + maxStreamMasks = 16 + maxCertFileBytes = 1 << 20 +) + +// CheckStreamBuildCost refuses stream settings that conf.Build(), which runs in the panel +// process, would expand without bound; settings the core cannot decode are left to it. +func CheckStreamBuildCost(raw []byte) error { + if len(raw) == 0 { + return nil + } + stream := new(conf.StreamConfig) + if json.Unmarshal(raw, stream) != nil { + return nil + } + return checkStreamBuildCost(stream) +} + +func checkStreamBuildCost(stream *conf.StreamConfig) error { + masks, hopPorts := 0, 0 + return walkStreams(stream, func(s *conf.StreamConfig, xhttp *conf.SplitHTTPConfig) error { + if xhttp != nil { + if length := xhttp.SessionIDLength; length.From < 0 || length.To > maxXHTTPSessionIDLength { + return common.NewErrorf("xhttp sessionIDLength %s is outside 0-%d", length.String(), maxXHTTPSessionIDLength) + } + } + if s.FinalMask == nil { + return nil + } + // Every mask costs a build, and an xmc one an RSA key derivation. + if masks += len(s.FinalMask.Tcp) + len(s.FinalMask.Udp); masks > maxStreamMasks { + return common.NewErrorf("finalmask lists more than %d masks", maxStreamMasks) + } + for _, mask := range s.FinalMask.Udp { + if !strings.EqualFold(mask.Type, "udphop") || mask.Settings == nil { + continue + } + hop := new(conf.UDPHop) + if json.Unmarshal(*mask.Settings, hop) != nil { + continue + } + for _, r := range hop.RemotePorts.Range { + // A bare number skips the core's range check, and its uint32 loop never ends on the largest. + if r.To > 65535 { + return common.NewErrorf("udphop remotePorts names port %d", r.To) + } + if r.From <= r.To { + hopPorts += int(r.To-r.From) + 1 + } + if hopPorts > maxUDPHopPorts { + return common.NewErrorf("udphop remotePorts names more than %d ports", maxUDPHopPorts) + } + } + } + return nil + }) +} + +// CountXMCMasks counts the xmc tcp masks of a stream, nested download streams included: +// each costs the core a fresh RSA key on every build. +func CountXMCMasks(raw []byte) int { + stream := new(conf.StreamConfig) + if len(raw) == 0 || json.Unmarshal(raw, stream) != nil { + return 0 + } + count := 0 + _ = walkStreams(stream, func(s *conf.StreamConfig, _ *conf.SplitHTTPConfig) error { + if s.FinalMask != nil { + for _, mask := range s.FinalMask.Tcp { + if strings.EqualFold(mask.Type, "xmc") { + count++ + } + } + } + return nil + }) + return count +} + +// walkStreams visits stream and the download streams nested in its xhttp settings, with the +// xhttp settings the core builds: xhttpSettings over splithttpSettings, "extra" over both. +func walkStreams(stream *conf.StreamConfig, visit func(*conf.StreamConfig, *conf.SplitHTTPConfig) error) error { + for stream != nil { + xhttp := stream.XHTTPSettings + if xhttp == nil { + xhttp = stream.SplitHTTPSettings + } + if xhttp != nil && xhttp.Extra != nil { + extra := new(conf.SplitHTTPConfig) + if json.Unmarshal(xhttp.Extra, extra) != nil { + extra = nil + } + xhttp = extra + } + if err := visit(stream, xhttp); err != nil { + return err + } + stream = nil + if xhttp != nil { + stream = xhttp.DownloadSettings + } + } + return nil +} + +// CheckConfigCertFiles refuses a certificateFile or keyFile, at any depth and in any key +// case, that is not a bounded regular file: the core reads it whole inside the panel. +func CheckConfigCertFiles(raw []byte) error { + var decoded any + if len(raw) == 0 || json.Unmarshal(raw, &decoded) != nil { + return nil + } + return checkCertFileKeys(decoded) +} + +func checkCertFileKeys(v any) error { + switch v := v.(type) { + case map[string]any: + for key, child := range v { + if path, ok := child.(string); ok && path != "" && (strings.EqualFold(key, "certificateFile") || strings.EqualFold(key, "keyFile")) { + if !filepath.IsAbs(path) { + path = platform.GetCertLocation(path) + } + if err := CheckCertFile(path); err != nil { + return err + } + } + if err := checkCertFileKeys(child); err != nil { + return err + } + } + case []any: + for _, child := range v { + if err := checkCertFileKeys(child); err != nil { + return err + } + } + } + return nil +} + +// CheckCertFile refuses a path that exists but is a device, FIFO or oversized file; a +// missing one is left to the reader, whose open fails at once. +func CheckCertFile(path string) error { + if path == "" { + return nil + } + info, err := os.Stat(path) + if err != nil { + return nil + } + // procfs nodes such as /proc/kmsg report a regular file of size 0. + if !info.Mode().IsRegular() || info.Size() == 0 || info.Size() > maxCertFileBytes { + return common.NewErrorf("certificate file %q must be a regular file of at most %d bytes", path, maxCertFileBytes) + } + return nil +} + +// checkUntrustedBuildInput runs before every in-process conf.Build(): inbounds arrive from +// node pushes and outbounds from third-party subscriptions. +func checkUntrustedBuildInput(stream *conf.StreamConfig, raw []byte) error { + if err := checkStreamBuildCost(stream); err != nil { + return err + } + return CheckConfigCertFiles(raw) +} diff --git a/internal/xray/outbound_validation_test.go b/internal/xray/outbound_validation_test.go index 34d2717e7..d882355c8 100644 --- a/internal/xray/outbound_validation_test.go +++ b/internal/xray/outbound_validation_test.go @@ -150,3 +150,37 @@ func TestValidateOutboundConfig_RejectsLinksTheHandlerConstructorRefuses(t *test }) } } + +// conf.Build() runs inside the panel; these values make it allocate or loop without +// bound, so they must be refused before it, whoever supplied the outbound. +func TestValidateOutboundConfig_RefusesWhatTheLoaderWouldExpandWithoutBound(t *testing.T) { + big := filepath.Join(t.TempDir(), "big.pem") + if err := os.WriteFile(big, make([]byte, maxCertFileBytes+1), 0o600); err != nil { + t.Fatalf("write cert: %v", err) + } + vless := func(stream string) string { + return `{"protocol": "vless", "settings": {"address": "1.2.3.4", "port": 443, "id": "b831381d-6324-4d53-ad4f-8cda48b30811", "encryption": "none"}, "streamSettings": ` + stream + `}` + } + hop := `{"type": "udphop", "settings": {"mode": "intervalRemote", "remotePorts": "0-65535"}}` + masks := strings.TrimSuffix(strings.Repeat(`{"type": "noise"},`, maxStreamMasks+1), ",") + for name, tc := range map[string]struct{ outbound, wantErr string }{ + "xhttp sessionIDLength in extra": {vless(`{"network": "xhttp", "security": "tls", "xhttpSettings": {"extra": {"sessionIDTable": "abcdefgh", "sessionIDLength": "2147483647"}}}`), "sessionIDLength"}, + "nested download sessionIDLength": {vless(`{"network": "xhttp", "security": "tls", "xhttpSettings": {"downloadSettings": {"network": "xhttp", "xhttpSettings": {"sessionIDTable": "ab", "sessionIDLength": "64-100000"}}}}`), "sessionIDLength"}, + "udphop ports across masks": {vless(`{"network": "tcp", "security": "tls", "finalmask": {"udp": [` + hop + `,` + hop + `]}}`), "udphop"}, + "udphop bare port": {vless(`{"network": "tcp", "security": "tls", "finalmask": {"UDP": [{"type": "UDPHOP", "settings": {"mode": "intervalRemote", "remotePorts": 4294967295}}]}}`), "udphop"}, + "too many masks": {vless(`{"network": "tcp", "security": "tls", "finalmask": {"tcp": [` + masks + `]}}`), "masks"}, + "certificate is a directory": {vless(`{"network": "tcp", "security": "tls", "tlsSettings": {"certificates": [{"CertificateFile": "` + filepath.ToSlash(t.TempDir()) + `"}]}}`), "regular file"}, + "oversized key file": {vless(`{"network": "tcp", "security": "tls", "tlsSettings": {"certificates": [{"keyFile": "` + filepath.ToSlash(big) + `"}]}}`), "regular file"}, + } { + if err := ValidateOutboundConfig([]byte(tc.outbound)); err == nil || !strings.Contains(err.Error(), tc.wantErr) { + t.Errorf("%s: ValidateOutboundConfig = %v, want an error naming %q", name, err, tc.wantErr) + } + } +} + +func TestCountXMCMasksFollowsTheStreamsTheCoreBuilds(t *testing.T) { + stream := `{"finalmask": {"tcp": [{"type": "XMC"}, {"type": "noise"}]}, "xhttpSettings": {"extra": {"downloadSettings": {"finalmask": {"tcp": [{"type": "xmc"}]}}}}}` + if got := CountXMCMasks([]byte(stream)); got != 2 { + t.Fatalf("CountXMCMasks = %d, want 2 (one top-level, one in the download stream inside extra)", got) + } +}