mirror of
https://github.com/Buriburizaem0n/nezha_domains.git
synced 2026-09-19 17:50:12 +00:00
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>
162 lines
4.3 KiB
Go
162 lines
4.3 KiB
Go
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]
|
||
|
||
idToDomain map[uint64]string
|
||
}
|
||
|
||
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 {
|
||
list[profile.Domain] = profile
|
||
idToDomain[profile.ID] = profile.Domain
|
||
}
|
||
|
||
return &NATClass{
|
||
class: class[string, *model.NAT]{
|
||
list: list,
|
||
sortedList: sortedList,
|
||
},
|
||
idToDomain: idToDomain,
|
||
}
|
||
}
|
||
|
||
func (c *NATClass) Update(n *model.NAT) {
|
||
c.listMu.Lock()
|
||
|
||
if oldDomain, ok := c.idToDomain[n.ID]; ok && oldDomain != n.Domain {
|
||
delete(c.list, oldDomain)
|
||
}
|
||
|
||
c.list[n.Domain] = n
|
||
c.idToDomain[n.ID] = n.Domain
|
||
|
||
c.listMu.Unlock()
|
||
c.sortList()
|
||
}
|
||
|
||
func (c *NATClass) Delete(idList []uint64) {
|
||
c.listMu.Lock()
|
||
|
||
for _, id := range idList {
|
||
if domain, ok := c.idToDomain[id]; ok {
|
||
delete(c.list, domain)
|
||
delete(c.idToDomain, id)
|
||
}
|
||
}
|
||
|
||
c.listMu.Unlock()
|
||
c.sortList()
|
||
}
|
||
|
||
func (c *NATClass) GetNATConfigByDomain(domain string) *model.NAT {
|
||
c.listMu.RLock()
|
||
defer c.listMu.RUnlock()
|
||
|
||
return c.list[domain]
|
||
}
|
||
|
||
func (c *NATClass) GetDomain(id uint64) string {
|
||
c.listMu.RLock()
|
||
defer c.listMu.RUnlock()
|
||
|
||
return c.idToDomain[id]
|
||
}
|
||
|
||
func (c *NATClass) sortList() {
|
||
c.listMu.RLock()
|
||
defer c.listMu.RUnlock()
|
||
|
||
sortedList := utils.MapValuesToSlice(c.list)
|
||
slices.SortFunc(sortedList, func(a, b *model.NAT) int {
|
||
return cmp.Compare(a.ID, b.ID)
|
||
})
|
||
|
||
c.sortedListMu.Lock()
|
||
defer c.sortedListMu.Unlock()
|
||
c.sortedList = sortedList
|
||
}
|