diff --git a/cmd/dashboard/controller/oauth2.go b/cmd/dashboard/controller/oauth2.go index a621e291..0cf5fed1 100644 --- a/cmd/dashboard/controller/oauth2.go +++ b/cmd/dashboard/controller/oauth2.go @@ -23,10 +23,11 @@ import ( // provider and is where the authorization code lands. Deriving it from the // raw Host header lets an attacker who can reach this handler with a forged // Host (or a provider with loose redirect-URI matching) divert a victim's -// code to their own origin and bind the victim's identity. Only trust the -// request Host when it is an operator-declared dashboard host (the same -// allowlist that guards NAT routing); otherwise fall back to the configured -// InstallHost so a forged Host cannot steer the redirect. +// code to their own origin and bind the victim's identity. A request Host is +// trusted only when it is an operator-declared dashboard host (the same +// allowlist that guards NAT routing). Otherwise the redirect is pinned to the +// operator-declared DashboardHost; when DashboardHost is empty the operator has +// not pinned a dashboard origin, so the request Host is passed through. func getRedirectURL(c *gin.Context) string { scheme := "http://" referer := c.Request.Referer() @@ -34,8 +35,8 @@ func getRedirectURL(c *gin.Context) string { scheme = "https://" } host := c.Request.Host - if !singleton.IsReservedDashboardHost(host) && singleton.Conf != nil && singleton.Conf.InstallHost != "" { - host = singleton.Conf.InstallHost + if !singleton.IsReservedDashboardHost(host) && singleton.Conf != nil && singleton.Conf.DashboardHost != "" { + host = singleton.Conf.DashboardHost } return scheme + host + "/api/v1/oauth2/callback" } diff --git a/cmd/dashboard/controller/oauth2_test.go b/cmd/dashboard/controller/oauth2_test.go index 0b0fe77f..ee972260 100644 --- a/cmd/dashboard/controller/oauth2_test.go +++ b/cmd/dashboard/controller/oauth2_test.go @@ -113,16 +113,18 @@ func TestOAuth2_VerifyState_HappyPath(t *testing.T) { } // GHSA-9rc6-8cjv-rcvx: getRedirectURL must not echo an attacker-controlled -// Host header into the OAuth2 callback URL. It may only trust a Host that the -// operator declared (InstallHost / ListenHost / ReservedHosts); any other Host -// must fall back to the configured InstallHost so a forged value cannot divert -// the victim's authorization code. +// Host header into the OAuth2 callback URL. When DashboardHost is set, only a +// Host the operator declared (DashboardHost / InstallHost / ListenHost / +// ReservedHosts) is trusted and any other Host is pinned to DashboardHost. When +// DashboardHost is empty the operator has not pinned a dashboard origin, so the +// request Host is passed through. -func setRedirectHostConf(t *testing.T, installHost, reservedHosts string) { +func setRedirectHostConf(t *testing.T, dashboardHost, installHost, reservedHosts string) { t.Helper() prev := singleton.Conf singleton.Conf = &singleton.ConfigClass{Config: &model.Config{ ConfigDashboard: model.ConfigDashboard{ + DashboardHost: dashboardHost, InstallHost: installHost, ReservedHosts: reservedHosts, }, @@ -130,28 +132,38 @@ func setRedirectHostConf(t *testing.T, installHost, reservedHosts string) { t.Cleanup(func() { singleton.Conf = prev }) } -func TestGetRedirectURL_RejectsForgedHostFallsBackToInstallHost(t *testing.T) { - setRedirectHostConf(t, "panel.example.com", "") +func TestGetRedirectURL_RejectsForgedHostFallsBackToDashboardHost(t *testing.T) { + setRedirectHostConf(t, "panel.example.com", "", "") c, _ := newOAuth2Ctx(t) c.Request.Host = "evil.attacker.test" got := getRedirectURL(c) require.Equal(t, "http://panel.example.com/api/v1/oauth2/callback", got, - "a forged Host must be ignored in favour of the configured InstallHost") + "a forged Host must be ignored in favour of the configured DashboardHost") } -func TestGetRedirectURL_TrustsInstallHost(t *testing.T) { - setRedirectHostConf(t, "panel.example.com", "") +func TestGetRedirectURL_EmptyDashboardHostPassesThroughRequestHost(t *testing.T) { + setRedirectHostConf(t, "", "agent.example.com", "") c, _ := newOAuth2Ctx(t) c.Request.Host = "panel.example.com" got := getRedirectURL(c) require.Equal(t, "http://panel.example.com/api/v1/oauth2/callback", got, - "the declared InstallHost must be trusted verbatim") + "when DashboardHost is empty the request Host must be passed through, decoupled from InstallHost") +} + +func TestGetRedirectURL_TrustsDashboardHost(t *testing.T) { + setRedirectHostConf(t, "panel.example.com", "", "") + c, _ := newOAuth2Ctx(t) + c.Request.Host = "panel.example.com" + + got := getRedirectURL(c) + require.Equal(t, "http://panel.example.com/api/v1/oauth2/callback", got, + "the declared DashboardHost must be trusted verbatim") } func TestGetRedirectURL_TrustsReservedHostForMultiDomain(t *testing.T) { - setRedirectHostConf(t, "panel.example.com", "alt.example.com,panel2.example.com") + setRedirectHostConf(t, "panel.example.com", "", "alt.example.com,panel2.example.com") c, _ := newOAuth2Ctx(t) c.Request.Host = "panel2.example.com" @@ -161,7 +173,7 @@ func TestGetRedirectURL_TrustsReservedHostForMultiDomain(t *testing.T) { } func TestGetRedirectURL_HonoursForwardedProtoOnTrustedHost(t *testing.T) { - setRedirectHostConf(t, "panel.example.com", "") + setRedirectHostConf(t, "panel.example.com", "", "") c, _ := newOAuth2Ctx(t) c.Request.Host = "panel.example.com" c.Request.Header.Set("X-Forwarded-Proto", "https") @@ -172,14 +184,14 @@ func TestGetRedirectURL_HonoursForwardedProtoOnTrustedHost(t *testing.T) { } func TestGetRedirectURL_ForgedHostCannotForceHTTPSOrigin(t *testing.T) { - setRedirectHostConf(t, "panel.example.com", "") + setRedirectHostConf(t, "panel.example.com", "", "") c, _ := newOAuth2Ctx(t) c.Request.Host = "evil.attacker.test" c.Request.Header.Set("X-Forwarded-Proto", "https") got := getRedirectURL(c) require.Equal(t, "https://panel.example.com/api/v1/oauth2/callback", got, - "even with an https hint the host must collapse to InstallHost, never the forged origin") + "even with an https hint the host must collapse to DashboardHost, never the forged origin") } func TestOAuth2_Unbind_UnknownProviderRejected(t *testing.T) { diff --git a/cmd/dashboard/controller/setting.go b/cmd/dashboard/controller/setting.go index 0a789cbb..aac58a56 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.DashboardHost = sf.DashboardHost singleton.Conf.ReservedHosts = sf.ReservedHosts singleton.Conf.IgnoredIPNotification = sf.IgnoredIPNotification singleton.Conf.IPChangeNotificationGroupID = sf.IPChangeNotificationGroupID diff --git a/model/config.go b/model/config.go index e3a1eea4..d432b0d6 100644 --- a/model/config.go +++ b/model/config.go @@ -45,6 +45,11 @@ type ConfigDashboard struct { InstallHost string `koanf:"install_host" json:"install_host,omitempty"` AgentTLS bool `koanf:"tls" json:"tls,omitempty"` // 用于前端判断生成的安装命令是否启用 TLS + // DashboardHost 是 dashboard 对外访问的主机名,专用于 OAuth2 回调地址。 + // 它与 InstallHost(agent 连接用主机名)解耦:两者可以是不同域名。 + // 为空时,OAuth2 回调放行请求 Host(信任请求头),不做强制重写。 + DashboardHost string `koanf:"dashboard_host" json:"dashboard_host,omitempty"` + WebRealIPHeader string `koanf:"web_real_ip_header" json:"web_real_ip_header,omitempty"` // 前端真实IP AgentRealIPHeader string `koanf:"agent_real_ip_header" json:"agent_real_ip_header,omitempty"` // Agent真实IP UserTemplate string `koanf:"user_template" json:"user_template,omitempty"` diff --git a/model/setting_api.go b/model/setting_api.go index 5822b88b..93418e73 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"` + DashboardHost string `json:"dashboard_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"` diff --git a/service/singleton/nat.go b/service/singleton/nat.go index 8327495b..75b594f6 100644 --- a/service/singleton/nat.go +++ b/service/singleton/nat.go @@ -29,7 +29,7 @@ func IsReservedDashboardHost(domain string) bool { return false } - hosts := []string{Conf.InstallHost, Conf.ListenHost} + hosts := []string{Conf.InstallHost, Conf.DashboardHost, Conf.ListenHost} hosts = append(hosts, strings.Split(Conf.ReservedHosts, ",")...) for _, host := range hosts { if reserved := splitDashboardHostname(host); reserved != "" && reserved == target {