mirror of
https://github.com/Buriburizaem0n/nezha_domains.git
synced 2026-09-19 09:40:12 +00:00
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:
@@ -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()
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user