mirror of
https://github.com/Buriburizaem0n/nezha_domains.git
synced 2026-09-19 17:50:12 +00:00
feat(oauth2): add dashboard_host setting for callback URL
Decouple OAuth2 callback host from agent install host. When dashboard_host is empty, the request Host is passed through; otherwise non-reserved hosts are pinned to dashboard_host. Added to reserved-host allowlist for NAT.
This commit is contained in:
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user