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 e8dabf5bc6
commit d4837dfc7a
10 changed files with 390 additions and 2 deletions
+70
View File
@@ -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 触发 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 {
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 {