diff --git a/cmd/dashboard/controller/nat.go b/cmd/dashboard/controller/nat.go index bc69a38b..519ca446 100644 --- a/cmd/dashboard/controller/nat.go +++ b/cmd/dashboard/controller/nat.go @@ -64,6 +64,10 @@ func createNAT(c *gin.Context) (uint64, error) { return 0, singleton.Localizer.ErrorT("permission denied") } + if singleton.IsReservedDashboardHost(nf.Domain) { + return 0, singleton.Localizer.ErrorT("permission denied") + } + uid := getUid(c) n.UserID = uid @@ -117,6 +121,10 @@ func updateNAT(c *gin.Context) (any, error) { return nil, singleton.Localizer.ErrorT("permission denied") } + if singleton.IsReservedDashboardHost(nf.Domain) { + return nil, singleton.Localizer.ErrorT("permission denied") + } + var n model.NAT if err = singleton.DB.First(&n, id).Error; err != nil { return nil, singleton.Localizer.ErrorT("profile id %d does not exist", id) diff --git a/cmd/dashboard/controller/setting.go b/cmd/dashboard/controller/setting.go index 1c0a270e..0a789cbb 100644 --- a/cmd/dashboard/controller/setting.go +++ b/cmd/dashboard/controller/setting.go @@ -99,6 +99,7 @@ func updateConfig(c *gin.Context) (any, error) { singleton.Conf.EnablePlainIPInNotification = sf.EnablePlainIPInNotification singleton.Conf.Cover = sf.Cover singleton.Conf.InstallHost = sf.InstallHost + singleton.Conf.ReservedHosts = sf.ReservedHosts singleton.Conf.IgnoredIPNotification = sf.IgnoredIPNotification singleton.Conf.IPChangeNotificationGroupID = sf.IPChangeNotificationGroupID singleton.Conf.SiteName = sf.SiteName diff --git a/cmd/dashboard/controller/setting_reserved_hosts_test.go b/cmd/dashboard/controller/setting_reserved_hosts_test.go new file mode 100644 index 00000000..92fabca8 --- /dev/null +++ b/cmd/dashboard/controller/setting_reserved_hosts_test.go @@ -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) + } +} diff --git a/model/config.go b/model/config.go index 2990d961..e3a1eea4 100644 --- a/model/config.go +++ b/model/config.go @@ -54,6 +54,11 @@ type ConfigDashboard struct { 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变更提醒 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"` diff --git a/model/setting_api.go b/model/setting_api.go index 9e136140..5822b88b 100644 --- a/model/setting_api.go +++ b/model/setting_api.go @@ -8,6 +8,7 @@ type SettingForm struct { SiteName string `json:"site_name,omitempty" minLength:"1"` Language string `json:"language,omitempty" minLength:"2"` InstallHost string `json:"install_host,omitempty" validate:"optional"` + ReservedHosts string `json:"reserved_hosts,omitempty" validate:"optional"` CustomCode string `json:"custom_code,omitempty" validate:"optional"` CustomCodeDashboard string `json:"custom_code_dashboard,omitempty" validate:"optional"` WebRealIPHeader string `json:"web_real_ip_header,omitempty" validate:"optional"` // 前端真实IP diff --git a/service/singleton/ddns.go b/service/singleton/ddns.go index 93619cd8..f44d1361 100644 --- a/service/singleton/ddns.go +++ b/service/singleton/ddns.go @@ -3,6 +3,7 @@ package singleton import ( "cmp" "fmt" + "log" "slices" "github.com/libdns/cloudflare" @@ -56,12 +57,30 @@ func (c *DDNSClass) Delete(idList []uint64) { 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)) c.listMu.RLock() for _, id := range profileId { 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) } else { c.listMu.RUnlock() diff --git a/service/singleton/ddns_worker_authz_test.go b/service/singleton/ddns_worker_authz_test.go new file mode 100644 index 00000000..f3d44adf --- /dev/null +++ b/service/singleton/ddns_worker_authz_test.go @@ -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)) + } +} diff --git a/service/singleton/nat.go b/service/singleton/nat.go index 7b5cefff..8327495b 100644 --- a/service/singleton/nat.go +++ b/service/singleton/nat.go @@ -2,12 +2,81 @@ package singleton import ( "cmp" + "net" + "net/netip" "slices" + "strings" "github.com/nezhahq/nezha/model" "github.com/nezhahq/nezha/pkg/utils" ) +// GHSA-x6fg-52vr-hj4w: NAT 是 commonHandler,任意认证成员可创建。newHTTPandGRPCMux +// 在分发 dashboard/gRPC 之前先按 r.Host 命中 NAT,故成员若把 Domain 设成 dashboard +// 自身 host 即可抢占全局路由(disabled 触发 DoS,enabled 把请求隧道到攻击者 agent)。 +// 把 dashboard 的 InstallHost、ListenHost 以及运维声明的 ReservedHosts 列为 +// 保留 host:create/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 { class[string, *model.NAT] @@ -18,6 +87,7 @@ func NewNATClass() *NATClass { var sortedList []*model.NAT DB.Find(&sortedList) + sortedList = filterReservedNATProfiles(sortedList) list := make(map[string]*model.NAT, len(sortedList)) idToDomain := make(map[uint64]string, len(sortedList)) for _, profile := range sortedList { diff --git a/service/singleton/nat_reserved_host_test.go b/service/singleton/nat_reserved_host_test.go new file mode 100644 index 00000000..c3b21d3c --- /dev/null +++ b/service/singleton/nat_reserved_host_test.go @@ -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") + } +} diff --git a/service/singleton/server.go b/service/singleton/server.go index f2822502..dcc18a26 100644 --- a/service/singleton/server.go +++ b/service/singleton/server.go @@ -126,7 +126,7 @@ func (c *ServerClass) UpdateDDNS(server *model.Server, ip *model.IP) error { confServers := strings.Split(Conf.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 { return err }