diff --git a/internal/database/model/model.go b/internal/database/model/model.go index ec84a53b7..2183bde85 100644 --- a/internal/database/model/model.go +++ b/internal/database/model/model.go @@ -356,7 +356,9 @@ func (i *Inbound) GenXrayInboundConfig() *xray.InboundConfig { if listen == "" { listen = "0.0.0.0" } - listen = fmt.Sprintf("\"%v\"", listen) + // Encoded, not quoted by hand: an escaped "env:" prefix must stay literal text. + encodedListen, _ := json.Marshal(listen) + listen = string(encodedListen) protocol := string(i.Protocol) settings := i.Settings switch i.Protocol { diff --git a/internal/web/service/inbound.go b/internal/web/service/inbound.go index 24c67327f..746747285 100644 --- a/internal/web/service/inbound.go +++ b/internal/web/service/inbound.go @@ -1310,6 +1310,11 @@ func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound, boo if err := validateInboundTLSCertificates(inbound.StreamSettings); err != nil { return inbound, false, err } + } else if err := validatePushedInbound(inbound, nil); err != nil { + return inbound, false, err + } + if err := validateInboundEnvRefs(inbound); err != nil { + return inbound, false, err } if err := validateInboundBuildInput(inbound); err != nil { return inbound, false, err @@ -2034,6 +2039,14 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound, if err := validateInboundBuildInput(inbound); err != nil { return inbound, false, err } + if err := validateInboundEnvRefs(inbound); err != nil { + return inbound, false, err + } + if s.FromNodeSync { + if err := validatePushedInbound(inbound, oldInbound); 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_push_guard.go b/internal/web/service/inbound_push_guard.go new file mode 100644 index 000000000..2e6f898f7 --- /dev/null +++ b/internal/web/service/inbound_push_guard.go @@ -0,0 +1,232 @@ +package service + +import ( + "encoding/json" + "fmt" + "net" + "net/netip" + "slices" + "strconv" + "strings" + + "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/common" +) + +// jsonLeaf is one scalar of a JSON document with the lowercased key path leading to it. +// Repeated keys each yield a leaf: xray-core binds keys case-folded, and the last wins. +type jsonLeaf struct { + path []string + value string +} + +func (l jsonLeaf) endsWith(suffix ...string) bool { + return len(l.path) >= len(suffix) && slices.Equal(l.path[len(l.path)-len(suffix):], suffix) +} + +// jsonLeaves walks raw token by token, so no repeated key hides behind another. +func jsonLeaves(raw string) []jsonLeaf { + dec := json.NewDecoder(strings.NewReader(raw)) + dec.UseNumber() + var leaves []jsonLeaf + var walk func(path []string) bool + walk = func(path []string) bool { + tok, err := dec.Token() + if err != nil { + return false + } + switch tok { + case json.Delim('{'): + for dec.More() { + key, err := dec.Token() + if err != nil { + return false + } + name, _ := key.(string) + if !walk(append(slices.Clone(path), strings.ToLower(name))) { + return false + } + } + _, err = dec.Token() + return err == nil + case json.Delim('['): + for dec.More() { + if !walk(append(slices.Clone(path), "[]")) { + return false + } + } + _, err = dec.Token() + return err == nil + } + leaves = append(leaves, jsonLeaf{path: path, value: fmt.Sprint(tok)}) + return true + } + walk(nil) + return leaves +} + +// isEnvRef matches what xray-core expands from the panel's environment: a whole value, or +// one entry of a comma list such as a port list. +func isEnvRef(value string) bool { + for part := range strings.SplitSeq(value, ",") { + if strings.HasPrefix(strings.TrimSpace(part), "env:") { + return true + } + } + return false +} + +// validateInboundEnvRefs refuses "env:NAME" anywhere the core would expand it: the panel's +// environment (database DSN, token key) must not reach a config or a load error quoting it. +func validateInboundEnvRefs(inbound *model.Inbound) error { + found := isEnvRef(inbound.Listen) + for _, raw := range []string{inbound.Settings, inbound.StreamSettings, inbound.Sniffing} { + for _, leaf := range jsonLeaves(raw) { + found = found || isEnvRef(leaf.value) + } + } + if found { + return common.NewError(`an inbound must not refer to environment variables ("env:")`) + } + return nil +} + +// pushReach is one field of a pushed inbound that names a resource on this host. +type pushReach struct { + field, value string +} + +// validatePushedInbound refuses, on a master's push, the fields that reach this host's own +// files, sockets and services; a value the stored row already carries was set here and stays. +func validatePushedInbound(inbound, stored *model.Inbound) error { + if (stored == nil || stored.Protocol != inbound.Protocol) && !nodeEligibleProtocols[inbound.Protocol] { + return common.NewErrorf("%s inbounds cannot be pushed to a node", inbound.Protocol) + } + reaches, err := hostReaches(inbound) + if err != nil { + return err + } + var allowed []pushReach + if stored != nil { + if allowed, err = hostReaches(stored); err != nil { + return err + } + } + for _, r := range reaches { + if !slices.Contains(allowed, r) { + return common.NewErrorf("a pushed inbound may not set %s to %q on this node", r.field, r.value) + } + } + return nil +} + +func hostReaches(inbound *model.Inbound) ([]pushReach, error) { + local, err := localRelayTargets(inbound.Id) + if err != nil { + return nil, err + } + var reaches []pushReach + add := func(field string, leaf jsonLeaf) { + reaches = append(reaches, pushReach{field: field, value: leaf.value}) + } + streamLeaves := jsonLeaves(inbound.StreamSettings) + // A whole object is one reach: a push keeping the type but moving the url or folder + // is a different reach than the one this node stores. + object := func(field string, container ...string) { + var parts []string + for _, l := range streamLeaves { + if i := slices.Index(l.path, container[len(container)-1]); i >= len(container)-1 && slices.Equal(l.path[i+1-len(container):i+1], container) { + parts = append(parts, strings.Join(l.path[i+1:], ".")+"="+l.value) + } + } + slices.Sort(parts) + reaches = append(reaches, pushReach{field: field, value: strings.Join(parts, ";")}) + } + for _, leaf := range streamLeaves { + lower := strings.ToLower(strings.TrimSpace(leaf.value)) + switch { + case leaf.endsWith("hysteriasettings", "masquerade", "type") && (lower == "file" || lower == "proxy"): + object("hysteria masquerade", "hysteriasettings", "masquerade") + case leaf.endsWith("masterkeylog") && lower != "" && lower != "none": + add("masterKeyLog", leaf) + case leaf.endsWith("xdrivesettings", "service") && (lower == "local" || lower == "template"): + object("xdrive service", "xdrivesettings") + case leaf.endsWith("echconfiglist") && strings.Contains(lower, "://"): + add("echConfigList", leaf) + case slices.Contains(leaf.path, "finalmask") && leaf.endsWith("type") && lower == "realm": + add("finalmask", leaf) + case leaf.endsWith("realitysettings", "target") || leaf.endsWith("realitysettings", "dest"): + if !isPublicRelayTarget(leaf.value) { + add("REALITY target", leaf) + } + } + } + for _, leaf := range jsonLeaves(inbound.Settings) { + switch { + case leaf.endsWith("fallbacks", "[]", "dest"): + if !isPublicRelayTarget(leaf.value) && !slices.Contains(local, normalizeRelayTarget(leaf.value)) { + add("fallback dest", leaf) + } + case leaf.endsWith("domainfronting", "ip"): + if ip, err := netip.ParseAddr(strings.TrimSpace(leaf.value)); err == nil && isBlockedIP(net.IP(ip.AsSlice())) { + add("domainFronting ip", leaf) + } + } + } + return reaches, nil +} + +// isPublicRelayTarget reports whether a relay target dials a public host, not this one: a +// bare port, a unix socket and a loopback or private host all stay on the machine. +func isPublicRelayTarget(target string) bool { + target = strings.TrimSpace(target) + if strings.HasPrefix(target, "/") || strings.HasPrefix(target, "@") { + return false + } + host, _, err := net.SplitHostPort(target) + if err != nil { + return false + } + return !namesInternalHost(host) +} + +// normalizeRelayTarget reduces a fallback dest to the form localRelayTargets lists. +func normalizeRelayTarget(target string) string { + target = strings.TrimSpace(target) + if strings.HasPrefix(target, "/") || strings.HasPrefix(target, "@") { + return target + } + if _, err := strconv.Atoi(target); err == nil { + return target + } + if _, port, err := net.SplitHostPort(target); err == nil { + return port + } + return target +} + +// localRelayTargets lists what this node's other inbounds listen on, the only local +// fallback dests a master generates; the core's own listeners are never among them. +func localRelayTargets(selfID int) ([]string, error) { + var rows []model.Inbound + if err := database.GetDB().Select("id", "listen", "port").Where("node_id IS NULL AND id <> ?", selfID).Find(&rows).Error; err != nil { + return nil, err + } + reserved := map[string]bool{strconv.Itoa(reservedAPIPort()): true} + for _, port := range xrayOwnPorts() { + reserved[port] = true + } + var targets []string + for _, ib := range rows { + port := strconv.Itoa(ib.Port) + if reserved[port] { + continue + } + targets = append(targets, port) + if listen := strings.TrimSpace(ib.Listen); strings.HasPrefix(listen, "/") || strings.HasPrefix(listen, "@") { + targets = append(targets, strings.SplitN(listen, ",", 2)[0]) + } + } + return targets, nil +} diff --git a/internal/web/service/inbound_tls_test.go b/internal/web/service/inbound_tls_test.go index 8c1a5f947..978a73d34 100644 --- a/internal/web/service/inbound_tls_test.go +++ b/internal/web/service/inbound_tls_test.go @@ -215,3 +215,67 @@ func TestAddInboundRefusesACertificatePathThatIsNoRegularFile(t *testing.T) { t.Fatal("refused push reached the runtime") } } + +// A node-sync token or the master's certificate pushes inbounds into this node's core, +// which runs as root: a push must not reach this host's files, sockets or own services. +func TestAddInboundRefusesPushedFieldsThatReachThisHost(t *testing.T) { + setupConflictDB(t) + mgr := useTestRuntimeManager(t) + mgr.SetLocalRuntimeOverride(&fakeNodeRuntime{}) + push := &InboundService{FromNodeSync: true} + if _, _, err := push.AddInbound(&model.Inbound{Tag: "web", Enable: true, Port: 44400, Protocol: model.VLESS, Settings: `{"clients":[]}`}); err != nil { + t.Fatalf("seed a local inbound: %v", err) + } + vless := func(settings, stream string) *model.Inbound { + return &model.Inbound{Enable: true, Port: 44401, Protocol: model.VLESS, Settings: settings, StreamSettings: stream} + } + for name, ib := range map[string]*model.Inbound{ + "file masquerade behind a repeated key": { + Enable: true, Port: 44401, Protocol: model.Hysteria, Settings: `{"clients":[]}`, + StreamSettings: `{"network":"hysteria","HysteriaSettings":{"masquerade":{"Type":"FILE","dir":"/"}},"hysteriaSettings":{"masquerade":{"type":"404"}}}`, + }, + "masterKeyLog": vless(`{"clients":[]}`, `{"network":"tcp","security":"tls","tlsSettings":{"masterKeyLog":"/root/.ssh/authorized_keys"}}`), + "tun inbound": {Enable: true, Port: 44401, Protocol: model.Protocol("tun"), Settings: `{}`}, + "fallback to the core API": vless(`{"clients":[],"fallbacks":[{"dest":62789}]}`, `{"network":"tcp"}`), + "REALITY target on loopback": vless(`{"clients":[]}`, `{"network":"tcp","security":"reality","realitySettings":{"target":"127.0.0.1:2053"}}`), + "local xdrive storage": vless(`{"clients":[]}`, `{"network":"xdrive","xdriveSettings":{"service":"local","remoteFolder":"/etc/x-ui"}}`), + } { + if _, _, err := push.AddInbound(ib); err == nil { + t.Errorf("%s: pushed inbound accepted", name) + } + } + for name, ib := range map[string]*model.Inbound{ + "fallback to another inbound here": vless(`{"clients":[],"fallbacks":[{"dest":44400}]}`, `{"network":"tcp"}`), + "public REALITY target": {Enable: true, Port: 44402, Protocol: model.VLESS, Settings: `{"clients":[]}`, StreamSettings: `{"network":"tcp","security":"reality","realitySettings":{"target":"www.example.com:443"}}`}, + } { + if _, _, err := push.AddInbound(ib); err != nil && strings.Contains(err.Error(), "pushed") { + t.Errorf("%s: refused: %v", name, err) + } + } + if _, _, err := (&InboundService{}).AddInbound(&model.Inbound{Enable: true, Listen: "env:XUI_DB_DSN", Port: 44403, Protocol: model.VLESS, Settings: `{"clients":[]}`}); err == nil || !strings.Contains(err.Error(), "env:") { + t.Fatalf("env: listen = %v, want refused for every caller", err) + } +} + +// What this node's own admin configured stays pushable: the master echoes the stored row +// back, and refusing it would stop that inbound syncing at all. +func TestUpdateInboundKeepsAHostReachTheNodeAlreadyStores(t *testing.T) { + setupConflictDB(t) + mgr := useTestRuntimeManager(t) + mgr.SetLocalRuntimeOverride(&fakeNodeRuntime{}) + stream := `{"network":"hysteria","hysteriaSettings":{"masquerade":{"type":"proxy","url":"https://news.example.com"}}}` + stored, _, err := (&InboundService{}).AddInbound(&model.Inbound{Tag: "hy", Enable: true, Port: 44410, Protocol: model.Hysteria, Settings: `{"clients":[]}`, StreamSettings: stream}) + if err != nil { + t.Fatalf("admin add: %v", err) + } + push := &InboundService{FromNodeSync: true} + echo := *stored + if _, _, err := push.UpdateInbound(&echo); err != nil { + t.Fatalf("pushing back the stored row: %v", err) + } + changed := *stored + changed.StreamSettings = `{"network":"hysteria","hysteriaSettings":{"masquerade":{"type":"proxy","url":"http://127.0.0.1:2053"}}}` + if _, _, err := push.UpdateInbound(&changed); err == nil { + t.Fatal("a push moved the masquerade proxy to the panel's own port") + } +}