mirror of
https://github.com/Buriburizaem0n/nezha_domains.git
synced 2026-09-21 18:50:13 +00:00
fix(oauth2): validate Host header against allowlist for callback URL
GHSA-9rc6-8cjv-rcvx: getRedirectURL derived the OAuth2 callback URL from the raw Host header, so a forged Host (or a provider with loose redirect-URI matching) could divert a victim's authorization code to an attacker origin and bind their identity. Trust the request Host only when it is an operator-declared dashboard host (IsReservedDashboardHost, same allowlist guarding NAT routing); otherwise fall back to the configured InstallHost.
This commit is contained in:
@@ -19,13 +19,25 @@ import (
|
|||||||
"github.com/nezhahq/nezha/service/singleton"
|
"github.com/nezhahq/nezha/service/singleton"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// GHSA-9rc6-8cjv-rcvx: the OAuth2 callback URL is sent to the identity
|
||||||
|
// 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.
|
||||||
func getRedirectURL(c *gin.Context) string {
|
func getRedirectURL(c *gin.Context) string {
|
||||||
scheme := "http://"
|
scheme := "http://"
|
||||||
referer := c.Request.Referer()
|
referer := c.Request.Referer()
|
||||||
if forwardedProto := c.Request.Header.Get("X-Forwarded-Proto"); forwardedProto == "https" || strings.HasPrefix(referer, "https://") {
|
if forwardedProto := c.Request.Header.Get("X-Forwarded-Proto"); forwardedProto == "https" || strings.HasPrefix(referer, "https://") {
|
||||||
scheme = "https://"
|
scheme = "https://"
|
||||||
}
|
}
|
||||||
return scheme + c.Request.Host + "/api/v1/oauth2/callback"
|
host := c.Request.Host
|
||||||
|
if !singleton.IsReservedDashboardHost(host) && singleton.Conf != nil && singleton.Conf.InstallHost != "" {
|
||||||
|
host = singleton.Conf.InstallHost
|
||||||
|
}
|
||||||
|
return scheme + host + "/api/v1/oauth2/callback"
|
||||||
}
|
}
|
||||||
|
|
||||||
// @Summary Get Oauth2 Redirect URL
|
// @Summary Get Oauth2 Redirect URL
|
||||||
|
|||||||
@@ -112,6 +112,76 @@ func TestOAuth2_VerifyState_HappyPath(t *testing.T) {
|
|||||||
require.Equal(t, model.RTypeBind, st.Action)
|
require.Equal(t, model.RTypeBind, st.Action)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
|
||||||
|
func setRedirectHostConf(t *testing.T, installHost, reservedHosts string) {
|
||||||
|
t.Helper()
|
||||||
|
prev := singleton.Conf
|
||||||
|
singleton.Conf = &singleton.ConfigClass{Config: &model.Config{
|
||||||
|
ConfigDashboard: model.ConfigDashboard{
|
||||||
|
InstallHost: installHost,
|
||||||
|
ReservedHosts: reservedHosts,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
t.Cleanup(func() { singleton.Conf = prev })
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetRedirectURL_RejectsForgedHostFallsBackToInstallHost(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")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetRedirectURL_TrustsInstallHost(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 InstallHost must be trusted verbatim")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetRedirectURL_TrustsReservedHostForMultiDomain(t *testing.T) {
|
||||||
|
setRedirectHostConf(t, "panel.example.com", "alt.example.com,panel2.example.com")
|
||||||
|
c, _ := newOAuth2Ctx(t)
|
||||||
|
c.Request.Host = "panel2.example.com"
|
||||||
|
|
||||||
|
got := getRedirectURL(c)
|
||||||
|
require.Equal(t, "http://panel2.example.com/api/v1/oauth2/callback", got,
|
||||||
|
"a Host listed in ReservedHosts must be trusted so multi-domain deployments keep working")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetRedirectURL_HonoursForwardedProtoOnTrustedHost(t *testing.T) {
|
||||||
|
setRedirectHostConf(t, "panel.example.com", "")
|
||||||
|
c, _ := newOAuth2Ctx(t)
|
||||||
|
c.Request.Host = "panel.example.com"
|
||||||
|
c.Request.Header.Set("X-Forwarded-Proto", "https")
|
||||||
|
|
||||||
|
got := getRedirectURL(c)
|
||||||
|
require.Equal(t, "https://panel.example.com/api/v1/oauth2/callback", got,
|
||||||
|
"https scheme must still be derived for reverse-proxy TLS termination")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetRedirectURL_ForgedHostCannotForceHTTPSOrigin(t *testing.T) {
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
|
||||||
func TestOAuth2_Unbind_UnknownProviderRejected(t *testing.T) {
|
func TestOAuth2_Unbind_UnknownProviderRejected(t *testing.T) {
|
||||||
defer setupOAuth2Test(t)()
|
defer setupOAuth2Test(t)()
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user