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