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
+64
View File
@@ -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")
}
}