fix(security): reserve dashboard hosts from NAT and re-check DDNS profile ownership at worker time

GHSA-x6fg-52vr-hj4w: NAT is a commonHandler any authenticated member can
create, and newHTTPandGRPCMux matches r.Host against NAT before dispatching
the dashboard/gRPC handlers, so a member could register the dashboard's own
host as a NAT Domain and hijack global routing. Add IsReservedDashboardHost
(InstallHost/ListenHost plus an operator-declared ReservedHosts list for
reverse-proxy deployments), reject reserved hosts on create/update, and drop
pre-planted records when building the startup cache.

GHSA-39g2-8x68-pmx8: bind-time CheckPermission let a member pre-bind a DDNS
profile ID that the victim would only create later. GetDDNSProvidersFromProfiles
now re-validates ownership by server owner UID and skips foreign-owned
profiles (UserID==0 is treated as a migration artifact, not an admin grant).

Co-authored-by: cloudcode <cloudcode@users.noreply.github.com>
This commit is contained in:
naiba
2026-05-31 02:04:35 +00:00
co-authored by cloudcode
parent ab25662ddd
commit f18232eafa
10 changed files with 390 additions and 2 deletions
+8
View File
@@ -64,6 +64,10 @@ func createNAT(c *gin.Context) (uint64, error) {
return 0, singleton.Localizer.ErrorT("permission denied") return 0, singleton.Localizer.ErrorT("permission denied")
} }
if singleton.IsReservedDashboardHost(nf.Domain) {
return 0, singleton.Localizer.ErrorT("permission denied")
}
uid := getUid(c) uid := getUid(c)
n.UserID = uid n.UserID = uid
@@ -117,6 +121,10 @@ func updateNAT(c *gin.Context) (any, error) {
return nil, singleton.Localizer.ErrorT("permission denied") return nil, singleton.Localizer.ErrorT("permission denied")
} }
if singleton.IsReservedDashboardHost(nf.Domain) {
return nil, singleton.Localizer.ErrorT("permission denied")
}
var n model.NAT var n model.NAT
if err = singleton.DB.First(&n, id).Error; err != nil { if err = singleton.DB.First(&n, id).Error; err != nil {
return nil, singleton.Localizer.ErrorT("profile id %d does not exist", id) return nil, singleton.Localizer.ErrorT("profile id %d does not exist", id)
+1
View File
@@ -99,6 +99,7 @@ func updateConfig(c *gin.Context) (any, error) {
singleton.Conf.EnablePlainIPInNotification = sf.EnablePlainIPInNotification singleton.Conf.EnablePlainIPInNotification = sf.EnablePlainIPInNotification
singleton.Conf.Cover = sf.Cover singleton.Conf.Cover = sf.Cover
singleton.Conf.InstallHost = sf.InstallHost singleton.Conf.InstallHost = sf.InstallHost
singleton.Conf.ReservedHosts = sf.ReservedHosts
singleton.Conf.IgnoredIPNotification = sf.IgnoredIPNotification singleton.Conf.IgnoredIPNotification = sf.IgnoredIPNotification
singleton.Conf.IPChangeNotificationGroupID = sf.IPChangeNotificationGroupID singleton.Conf.IPChangeNotificationGroupID = sf.IPChangeNotificationGroupID
singleton.Conf.SiteName = sf.SiteName singleton.Conf.SiteName = sf.SiteName
@@ -0,0 +1,24 @@
package controller
import (
"bytes"
"encoding/json"
"testing"
"github.com/nezhahq/nezha/model"
)
// GHSA-x6fg-52vr-hj4w: the admin settings endpoint must accept reserved_hosts
// so a reverse-proxy operator can declare the public dashboard hostnames the
// process itself never sees. Without binding here, the only way to set the
// field would be hand-editing the YAML, defeating the in-product guard.
func TestSettingForm_BindsReservedHosts(t *testing.T) {
body := []byte(`{"site_name":"X","reserved_hosts":"panel.example.com, admin.example.com"}`)
var sf model.SettingForm
if err := json.NewDecoder(bytes.NewReader(body)).Decode(&sf); err != nil {
t.Fatal(err)
}
if sf.ReservedHosts != "panel.example.com, admin.example.com" {
t.Fatalf("reserved_hosts must decode into SettingForm, got %q", sf.ReservedHosts)
}
}
+5
View File
@@ -54,6 +54,11 @@ type ConfigDashboard struct {
EnableMCP bool `koanf:"enable_mcp" json:"enable_mcp,omitempty"` // 是否启用 MCP 入口(默认关闭;启用前请审视 PAT scope/whitelist EnableMCP bool `koanf:"enable_mcp" json:"enable_mcp,omitempty"` // 是否启用 MCP 入口(默认关闭;启用前请审视 PAT scope/whitelist
// GHSA-x6fg-52vr-hj4w:反代部署下 dashboard 的对外域名进程自身看不到,
// InstallHost/ListenHost 无法覆盖。运维在此用逗号分隔声明这些对外 host,
// 成员便无法注册与之冲突的 NAT 域名抢占路由。
ReservedHosts string `koanf:"reserved_hosts" json:"reserved_hosts,omitempty"`
// IP变更提醒 // IP变更提醒
EnableIPChangeNotification bool `koanf:"enable_ip_change_notification" json:"enable_ip_change_notification,omitempty"` EnableIPChangeNotification bool `koanf:"enable_ip_change_notification" json:"enable_ip_change_notification,omitempty"`
IPChangeNotificationGroupID uint64 `koanf:"ip_change_notification_group_id" json:"ip_change_notification_group_id"` IPChangeNotificationGroupID uint64 `koanf:"ip_change_notification_group_id" json:"ip_change_notification_group_id"`
+1
View File
@@ -8,6 +8,7 @@ type SettingForm struct {
SiteName string `json:"site_name,omitempty" minLength:"1"` SiteName string `json:"site_name,omitempty" minLength:"1"`
Language string `json:"language,omitempty" minLength:"2"` Language string `json:"language,omitempty" minLength:"2"`
InstallHost string `json:"install_host,omitempty" validate:"optional"` InstallHost string `json:"install_host,omitempty" validate:"optional"`
ReservedHosts string `json:"reserved_hosts,omitempty" validate:"optional"`
CustomCode string `json:"custom_code,omitempty" validate:"optional"` CustomCode string `json:"custom_code,omitempty" validate:"optional"`
CustomCodeDashboard string `json:"custom_code_dashboard,omitempty" validate:"optional"` CustomCodeDashboard string `json:"custom_code_dashboard,omitempty" validate:"optional"`
WebRealIPHeader string `json:"web_real_ip_header,omitempty" validate:"optional"` // 前端真实IP WebRealIPHeader string `json:"web_real_ip_header,omitempty" validate:"optional"` // 前端真实IP
+20 -1
View File
@@ -3,6 +3,7 @@ package singleton
import ( import (
"cmp" "cmp"
"fmt" "fmt"
"log"
"slices" "slices"
"github.com/libdns/cloudflare" "github.com/libdns/cloudflare"
@@ -56,12 +57,30 @@ func (c *DDNSClass) Delete(idList []uint64) {
c.sortList() c.sortList()
} }
func (c *DDNSClass) GetDDNSProvidersFromProfiles(profileId []uint64, ip *model.IP) ([]*ddns2.Provider, error) { // profileOwnedByRealAdmin reports whether uid is a genuine admin user that
// may share its DDNS profiles globally. userIsAdmin(0) returns true as a
// "system resource" shortcut, but a profile with UserID==0 is a migration /
// default-value artifact, not an admin grant — sharing it with foreign server
// owners reopens GHSA-39g2-8x68-pmx8. A real admin always has a non-zero ID.
func profileOwnedByRealAdmin(uid uint64) bool {
return uid != 0 && userIsAdmin(uid)
}
// GHSA-39g2-8x68-pmx8: bind-time CheckPermission 对「不存在的 profile ID」放行,
// 攻击者可预绑定将来才会被受害者创建的自增 ID。worker 解析时必须按 ownerUID
// 重新校验归属,跳过非 server owner(且非管理员)所有的 profile。
func (c *DDNSClass) GetDDNSProvidersFromProfiles(profileId []uint64, ip *model.IP, ownerUID uint64) ([]*ddns2.Provider, error) {
profiles := make([]*model.DDNSProfile, 0, len(profileId)) profiles := make([]*model.DDNSProfile, 0, len(profileId))
c.listMu.RLock() c.listMu.RLock()
for _, id := range profileId { for _, id := range profileId {
if profile, ok := c.list[id]; ok { if profile, ok := c.list[id]; ok {
if profile.UserID != ownerUID && !profileOwnedByRealAdmin(profile.UserID) {
// Fail-closed skip: an admin may bind a member-owned profile,
// but worker-time only runs same-owner or real-admin profiles.
log.Printf("NEZHA>> Skipping DDNS profile %d (owner %d) for server owner %d: not owned by server owner or a real admin", profile.ID, profile.UserID, ownerUID)
continue
}
profiles = append(profiles, profile) profiles = append(profiles, profile)
} else { } else {
c.listMu.RUnlock() c.listMu.RUnlock()
+121
View File
@@ -0,0 +1,121 @@
package singleton
import (
"testing"
"github.com/nezhahq/nezha/model"
)
// newDDNSClassForTest builds a DDNSClass backed by an in-memory profile map,
// mirroring the production cache layout without touching the database.
func newDDNSClassForTest(profiles ...*model.DDNSProfile) *DDNSClass {
list := make(map[uint64]*model.DDNSProfile, len(profiles))
for _, p := range profiles {
list[p.ID] = p
}
return &DDNSClass{
class: class[uint64, *model.DDNSProfile]{
list: list,
sortedList: profiles,
},
}
}
// GHSA-39g2-8x68-pmx8: a server owned by the attacker must not be able to
// drive a DDNS update through a DDNS profile owned by another (victim) user.
// The worker-time resolution must skip foreign-owned profiles.
func TestGetDDNSProvidersSkipsForeignOwnedProfile(t *testing.T) {
replaceUserInfoMapForSecurityTest(t, map[uint64]model.UserInfo{
100: {Role: model.RoleMember}, // attacker / server owner
200: {Role: model.RoleMember}, // victim / profile owner
})
victimProfile := &model.DDNSProfile{
Common: model.Common{ID: 1, UserID: 200},
Provider: model.ProviderDummy,
Name: "victim-profile",
AccessSecret: "victim-secret",
}
dc := newDDNSClassForTest(victimProfile)
providers, err := dc.GetDDNSProvidersFromProfiles([]uint64{1}, &model.IP{}, 100)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(providers) != 0 {
t.Fatalf("expected foreign-owned profile to be skipped, got %d provider(s)", len(providers))
}
}
// A server owner using their own DDNS profile must still resolve normally.
func TestGetDDNSProvidersAllowsOwnedProfile(t *testing.T) {
replaceUserInfoMapForSecurityTest(t, map[uint64]model.UserInfo{
100: {Role: model.RoleMember},
})
ownProfile := &model.DDNSProfile{
Common: model.Common{ID: 5, UserID: 100},
Provider: model.ProviderDummy,
Name: "own-profile",
}
dc := newDDNSClassForTest(ownProfile)
providers, err := dc.GetDDNSProvidersFromProfiles([]uint64{5}, &model.IP{}, 100)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(providers) != 1 {
t.Fatalf("expected owned profile to resolve, got %d provider(s)", len(providers))
}
}
// An admin-owned profile may be shared across servers (admin resources are
// global), so an admin profile resolves regardless of the server owner.
func TestGetDDNSProvidersAllowsAdminOwnedProfile(t *testing.T) {
replaceUserInfoMapForSecurityTest(t, map[uint64]model.UserInfo{
1: {Role: model.RoleAdmin}, // admin / profile owner
100: {Role: model.RoleMember},
})
adminProfile := &model.DDNSProfile{
Common: model.Common{ID: 9, UserID: 1},
Provider: model.ProviderDummy,
Name: "admin-profile",
}
dc := newDDNSClassForTest(adminProfile)
providers, err := dc.GetDDNSProvidersFromProfiles([]uint64{9}, &model.IP{}, 100)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(providers) != 1 {
t.Fatalf("expected admin-owned profile to resolve, got %d provider(s)", len(providers))
}
}
// GHSA-39g2-8x68-pmx8 (UserID==0 variant): userIsAdmin(0) returns true as a
// "system resource" shortcut, but a DDNS profile with UserID==0 is not a real
// admin grant — it is a migration/default-value artifact. A foreign server
// owner must NOT be able to drive an update through such a profile, so the
// worker must skip a UserID==0 profile that the caller does not own.
func TestGetDDNSProvidersSkipsUnownedZeroUserProfile(t *testing.T) {
replaceUserInfoMapForSecurityTest(t, map[uint64]model.UserInfo{
100: {Role: model.RoleMember}, // attacker / server owner
})
orphanProfile := &model.DDNSProfile{
Common: model.Common{ID: 3, UserID: 0},
Provider: model.ProviderDummy,
Name: "orphan-profile",
AccessSecret: "orphan-secret",
}
dc := newDDNSClassForTest(orphanProfile)
providers, err := dc.GetDDNSProvidersFromProfiles([]uint64{3}, &model.IP{}, 100)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(providers) != 0 {
t.Fatalf("expected UserID==0 foreign profile to be skipped, got %d provider(s)", len(providers))
}
}
+70
View File
@@ -2,12 +2,81 @@ package singleton
import ( import (
"cmp" "cmp"
"net"
"net/netip"
"slices" "slices"
"strings"
"github.com/nezhahq/nezha/model" "github.com/nezhahq/nezha/model"
"github.com/nezhahq/nezha/pkg/utils" "github.com/nezhahq/nezha/pkg/utils"
) )
// GHSA-x6fg-52vr-hj4w: NAT 是 commonHandler,任意认证成员可创建。newHTTPandGRPCMux
// 在分发 dashboard/gRPC 之前先按 r.Host 命中 NAT,故成员若把 Domain 设成 dashboard
// 自身 host 即可抢占全局路由(disabled 触发 DoSenabled 把请求隧道到攻击者 agent)。
// 把 dashboard 的 InstallHost、ListenHost 以及运维声明的 ReservedHosts 列为
// 保留 hostcreate/update 时拒绝,启动建表时丢弃,确保补丁前已植入的恶意记录
// 在升级后不再生效。每个 host 拆成 hostname 后比较(忽略端口与大小写),反代/
// 默认端口下的端口变体也拦得住。反代部署时进程看不到对外域名,运维把它配进
// Conf.ReservedHosts(逗号分隔)即可让此处覆盖到公网入口。
func IsReservedDashboardHost(domain string) bool {
if Conf == nil {
return false
}
target := splitDashboardHostname(domain)
if target == "" {
return false
}
hosts := []string{Conf.InstallHost, Conf.ListenHost}
hosts = append(hosts, strings.Split(Conf.ReservedHosts, ",")...)
for _, host := range hosts {
if reserved := splitDashboardHostname(host); reserved != "" && reserved == target {
return true
}
}
return false
}
// splitDashboardHostname 归一化为小写 hostname,对 bracketed IPv6[::1]、
// [::1]:8008)与裸 host:port 一视同仁,避免 candidate 与 reserved 解析形态不
// 一致导致漏拦。两类等价形态也必须收敛,否则 guard 放行而 r.Host 精确命中
// 仍能劫持路由:
// - DNS absolute name 的尾点(panel.example.com. 与 panel.example.com 指向同
// 一主机),去掉单个尾点;
// - IP literal 的压缩/展开写法(::1 与 0:0:0:0:0:0:0:1),用 netip 归一到
// 规范文本。
func splitDashboardHostname(host string) string {
host = strings.ToLower(strings.TrimSpace(host))
if host == "" {
return ""
}
if h, _, err := net.SplitHostPort(host); err == nil && h != "" {
host = h
} else {
host = strings.Trim(host, "[]")
}
host = strings.TrimSuffix(host, ".")
if addr, err := netip.ParseAddr(host); err == nil {
return addr.String()
}
return host
}
// filterReservedNATProfiles 丢弃 Domain 命中 dashboard 保留 host 的 NAT 记录,
// 让 NewNATClass 启动建表时不把补丁前植入的劫持记录加载进路由表。
func filterReservedNATProfiles(in []*model.NAT) []*model.NAT {
out := in[:0]
for _, profile := range in {
if profile == nil || IsReservedDashboardHost(profile.Domain) {
continue
}
out = append(out, profile)
}
return out
}
type NATClass struct { type NATClass struct {
class[string, *model.NAT] class[string, *model.NAT]
@@ -18,6 +87,7 @@ func NewNATClass() *NATClass {
var sortedList []*model.NAT var sortedList []*model.NAT
DB.Find(&sortedList) DB.Find(&sortedList)
sortedList = filterReservedNATProfiles(sortedList)
list := make(map[string]*model.NAT, len(sortedList)) list := make(map[string]*model.NAT, len(sortedList))
idToDomain := make(map[uint64]string, len(sortedList)) idToDomain := make(map[uint64]string, len(sortedList))
for _, profile := range sortedList { for _, profile := range sortedList {
+139
View File
@@ -0,0 +1,139 @@
package singleton
import (
"testing"
"github.com/nezhahq/nezha/model"
)
func withReservedHostConf(t *testing.T, c *model.Config) {
t.Helper()
original := Conf
Conf = &ConfigClass{Config: c}
t.Cleanup(func() { Conf = original })
}
// GHSA-x6fg-52vr-hj4w: the reserved-host check is the single source of truth
// for both the create/update guard and the startup cache filter. It must
// reject any NAT domain whose hostname collides with the dashboard's own
// InstallHost / ListenHost, regardless of port or case.
func TestIsReservedDashboardHost(t *testing.T) {
withReservedHostConf(t, &model.Config{
ConfigDashboard: model.ConfigDashboard{InstallHost: "dashboard.example:8008"},
ListenHost: "10.0.0.5",
ListenPort: 8008,
})
cases := []struct {
name string
domain string
want bool
}{
{"exact install host", "dashboard.example:8008", true},
{"install host case-insensitive", "Dashboard.Example:8008", true},
{"install host without port", "dashboard.example", true},
{"install host arbitrary port", "dashboard.example:8443", true},
{"listen host and port", "10.0.0.5:8008", true},
{"listen host bare", "10.0.0.5", true},
{"unrelated domain", "tunnel.member.example", false},
{"empty domain", "", false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := IsReservedDashboardHost(tc.domain); got != tc.want {
t.Fatalf("IsReservedDashboardHost(%q) = %v, want %v", tc.domain, got, tc.want)
}
})
}
}
// GHSA-x6fg-52vr-hj4w (reverse-proxy coverage): InstallHost/ListenHost alone
// cannot cover a dashboard reached through a reverse proxy on a public domain
// that the dashboard process never sees. ReservedHosts lets the operator
// declare those extra hostnames (comma-separated) so members still cannot
// register a NAT domain that collides with the public entry point.
func TestIsReservedDashboardHostHonoursReservedHostsList(t *testing.T) {
withReservedHostConf(t, &model.Config{
ConfigDashboard: model.ConfigDashboard{
InstallHost: "internal.example:8008",
ReservedHosts: "panel.example.com, Admin.Example.COM:443 , ",
},
})
reserved := []string{
"panel.example.com",
"panel.example.com:8443",
"admin.example.com",
"ADMIN.EXAMPLE.COM:443",
"internal.example",
}
for _, d := range reserved {
if !IsReservedDashboardHost(d) {
t.Errorf("IsReservedDashboardHost(%q) = false, want true (declared reserved host)", d)
}
}
if IsReservedDashboardHost("tunnel.member.example") {
t.Error("unrelated member domain must not be reserved")
}
if IsReservedDashboardHost("") {
t.Error("empty domain must not be reserved")
}
}
// The startup cache must not load a NAT record whose domain is reserved, so a
// malicious record planted before the patch cannot keep hijacking dashboard
// routing after upgrade. filterReservedNATProfiles is the gate NewNATClass
// runs over the DB result set.
func TestFilterReservedNATProfilesDropsReserved(t *testing.T) {
withReservedHostConf(t, &model.Config{
ConfigDashboard: model.ConfigDashboard{InstallHost: "dashboard.example:8008"},
})
in := []*model.NAT{
{Common: model.Common{ID: 1}, Domain: "dashboard.example", Enabled: true},
{Common: model.Common{ID: 2}, Domain: "tunnel.member.example", Enabled: true},
{Common: model.Common{ID: 3}, Domain: "Dashboard.Example:9999", Enabled: false},
}
out := filterReservedNATProfiles(in)
if len(out) != 1 {
t.Fatalf("expected only the non-reserved profile to survive, got %d", len(out))
}
if out[0].Domain != "tunnel.member.example" {
t.Fatalf("surviving profile must be the member tunnel, got %q", out[0].Domain)
}
}
// GHSA-x6fg-52vr-hj4w (canonical-host coverage): the routing match is an exact
// lookup on r.Host, so a member who registers a NAT Domain that is a DNS/IP
// *equivalent* of the dashboard host — but a different literal string — still
// hijacks the matching r.Host. The guard must collapse the trailing DNS dot and
// the IPv6 compressed/expanded forms, or these variants slip past create/update.
func TestIsReservedDashboardHostCollapsesEquivalentForms(t *testing.T) {
withReservedHostConf(t, &model.Config{
ConfigDashboard: model.ConfigDashboard{
InstallHost: "panel.example.com",
ReservedHosts: "[::1]:8008",
},
})
reserved := []string{
"panel.example.com.", // trailing dot, no port
"panel.example.com.:8008", // trailing dot with port
"PANEL.EXAMPLE.COM.", // trailing dot, mixed case
"[0:0:0:0:0:0:0:1]:8008", // IPv6 expanded form of ::1
"::1", // IPv6 compressed, bare
"[::1]", // IPv6 compressed, bracketed
}
for _, d := range reserved {
if !IsReservedDashboardHost(d) {
t.Errorf("IsReservedDashboardHost(%q) = false, want true (equivalent of reserved host)", d)
}
}
if IsReservedDashboardHost("tunnel.member.example.") {
t.Error("unrelated member domain with trailing dot must not be reserved")
}
}
+1 -1
View File
@@ -126,7 +126,7 @@ func (c *ServerClass) UpdateDDNS(server *model.Server, ip *model.IP) error {
confServers := strings.Split(Conf.DNSServers, ",") confServers := strings.Split(Conf.DNSServers, ",")
ctx := context.WithValue(context.Background(), ddns.DNSServerKey{}, utils.IfOr(confServers[0] != "", confServers, utils.DNSServers)) ctx := context.WithValue(context.Background(), ddns.DNSServerKey{}, utils.IfOr(confServers[0] != "", confServers, utils.DNSServers))
providers, err := DDNSShared.GetDDNSProvidersFromProfiles(server.DDNSProfiles, utils.IfOr(ip != nil, ip, &server.GeoIP.IP)) providers, err := DDNSShared.GetDDNSProvidersFromProfiles(server.DDNSProfiles, utils.IfOr(ip != nil, ip, &server.GeoIP.IP), server.GetUserID())
if err != nil { if err != nil {
return err return err
} }