fix(node): keep pushed inbounds off the node host's own resources

A node-sync token, or a master enrolled with the mTLS certificate, may
push inbounds, and the node stored them as already validated. An
inbound is effectively config for a core running as root, so a push
could serve the host's files (hysteria file masquerade), relay to its
own services or the core API (masquerade proxy, fallback dest, REALITY
target), write files (masterKeyLog), use local or templated XDRIVE
storage, fetch URLs (ECH, realm masks), or run tun/http/mixed inbounds.

A push now refuses those fields unless the node already stores the same
value, which keeps rows configured by the node's own admin syncing:
relay targets must be public or another of this node's inbounds, and
only node-eligible protocols are accepted. Every caller is refused env:
references, which the core expands from the panel's environment, and
the inbound listen address is JSON-encoded into the config instead of
quoted by hand.
This commit is contained in:
MHSanaei
2026-10-31 23:30:02 +01:00
parent e1950b7575
commit f66e369e41
4 changed files with 312 additions and 1 deletions
+3 -1
View File
@@ -356,7 +356,9 @@ func (i *Inbound) GenXrayInboundConfig() *xray.InboundConfig {
if listen == "" { if listen == "" {
listen = "0.0.0.0" 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) protocol := string(i.Protocol)
settings := i.Settings settings := i.Settings
switch i.Protocol { switch i.Protocol {
+13
View File
@@ -1310,6 +1310,11 @@ func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound, boo
if err := validateInboundTLSCertificates(inbound.StreamSettings); err != nil { if err := validateInboundTLSCertificates(inbound.StreamSettings); err != nil {
return inbound, false, err 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 { if err := validateInboundBuildInput(inbound); err != nil {
return inbound, false, err return inbound, false, err
@@ -2034,6 +2039,14 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound,
if err := validateInboundBuildInput(inbound); err != nil { if err := validateInboundBuildInput(inbound); err != nil {
return inbound, false, err 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; // Grandfather a row that was already stored incomplete so it stays editable;
// only a save that breaks a previously valid TLS block is refused. // only a save that breaks a previously valid TLS block is refused.
if !s.FromNodeSync { if !s.FromNodeSync {
+232
View File
@@ -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
}
+64
View File
@@ -215,3 +215,67 @@ func TestAddInboundRefusesACertificatePathThatIsNoRegularFile(t *testing.T) {
t.Fatal("refused push reached the runtime") 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")
}
}