mirror of
https://github.com/Buriburizaem0n/nezha_domains.git
synced 2026-09-19 17:50:12 +00:00
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.
279 lines
8.9 KiB
Go
279 lines
8.9 KiB
Go
package controller
|
|
|
|
import (
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/patrickmn/go-cache"
|
|
"github.com/stretchr/testify/require"
|
|
"gorm.io/driver/sqlite"
|
|
"gorm.io/gorm"
|
|
|
|
"github.com/nezhahq/nezha/model"
|
|
"github.com/nezhahq/nezha/pkg/i18n"
|
|
"github.com/nezhahq/nezha/service/singleton"
|
|
)
|
|
|
|
// OAuth2 callback 测试核心安全语义:state CSRF、provider 校验、解绑权限。
|
|
//
|
|
// 这些测试用 verifyState 的私有路径直接构造场景,因为 callback 的完整链路涉及
|
|
// 真实 IdP HTTP 调用;safety-critical 的 state 校验本身可以单测。
|
|
|
|
func setupOAuth2Test(t *testing.T) func() {
|
|
t.Helper()
|
|
originalDB := singleton.DB
|
|
originalConf := singleton.Conf
|
|
originalCache := singleton.Cache
|
|
originalLocalizer := singleton.Localizer
|
|
if singleton.Localizer == nil {
|
|
singleton.Localizer = i18n.NewLocalizer("en_US", "nezha", "translations", i18n.Translations)
|
|
}
|
|
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
|
require.NoError(t, err)
|
|
require.NoError(t, db.AutoMigrate(&model.User{}, &model.Oauth2Bind{}, &model.WAF{}))
|
|
singleton.DB = db
|
|
singleton.Conf = &singleton.ConfigClass{Config: &model.Config{
|
|
Oauth2: map[string]*model.Oauth2Config{
|
|
"github": {ClientID: "x", ClientSecret: "y"},
|
|
},
|
|
}}
|
|
singleton.Cache = cache.New(time.Minute, time.Minute)
|
|
|
|
return func() {
|
|
singleton.DB = originalDB
|
|
singleton.Conf = originalConf
|
|
singleton.Cache = originalCache
|
|
singleton.Localizer = originalLocalizer
|
|
}
|
|
}
|
|
|
|
func newOAuth2Ctx(t *testing.T) (*gin.Context, *httptest.ResponseRecorder) {
|
|
t.Helper()
|
|
gin.SetMode(gin.TestMode)
|
|
w := httptest.NewRecorder()
|
|
c, _ := gin.CreateTestContext(w)
|
|
c.Request = httptest.NewRequest("GET", "/oauth2/callback", nil)
|
|
c.Set(model.CtxKeyRealIPStr, "1.2.3.4")
|
|
return c, w
|
|
}
|
|
|
|
func TestOAuth2_VerifyState_RejectsMissingCookie(t *testing.T) {
|
|
defer setupOAuth2Test(t)()
|
|
c, _ := newOAuth2Ctx(t)
|
|
|
|
_, err := verifyState(c, "any-state-value")
|
|
require.Error(t, err, "missing nz-o2s cookie must be rejected")
|
|
}
|
|
|
|
func TestOAuth2_VerifyState_RejectsUnknownCookie(t *testing.T) {
|
|
defer setupOAuth2Test(t)()
|
|
c, _ := newOAuth2Ctx(t)
|
|
c.Request.AddCookie(&http.Cookie{Name: "nz-o2s", Value: "never-issued-key"})
|
|
|
|
_, err := verifyState(c, "any-state")
|
|
require.Error(t, err, "unknown state key (no cache entry) must be rejected")
|
|
}
|
|
|
|
func TestOAuth2_VerifyState_RejectsStateMismatch(t *testing.T) {
|
|
defer setupOAuth2Test(t)()
|
|
c, _ := newOAuth2Ctx(t)
|
|
|
|
stateKey := "k-1"
|
|
singleton.Cache.Set(
|
|
fmt.Sprintf("%s%s", model.CacheKeyOauth2State, stateKey),
|
|
&model.Oauth2State{State: "real-state", Provider: "github"},
|
|
cache.DefaultExpiration,
|
|
)
|
|
c.Request.AddCookie(&http.Cookie{Name: "nz-o2s", Value: stateKey})
|
|
|
|
_, err := verifyState(c, "forged-state")
|
|
require.Error(t, err, "attacker-supplied state that differs from cached must be rejected (CSRF defense)")
|
|
}
|
|
|
|
func TestOAuth2_VerifyState_HappyPath(t *testing.T) {
|
|
defer setupOAuth2Test(t)()
|
|
c, _ := newOAuth2Ctx(t)
|
|
|
|
stateKey := "k-ok"
|
|
singleton.Cache.Set(
|
|
fmt.Sprintf("%s%s", model.CacheKeyOauth2State, stateKey),
|
|
&model.Oauth2State{State: "good-state", Provider: "github", Action: model.RTypeBind},
|
|
cache.DefaultExpiration,
|
|
)
|
|
c.Request.AddCookie(&http.Cookie{Name: "nz-o2s", Value: stateKey})
|
|
|
|
st, err := verifyState(c, "good-state")
|
|
require.NoError(t, err)
|
|
require.Equal(t, "github", st.Provider)
|
|
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. 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, 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,
|
|
},
|
|
}}
|
|
t.Cleanup(func() { singleton.Conf = prev })
|
|
}
|
|
|
|
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 DashboardHost")
|
|
}
|
|
|
|
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,
|
|
"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")
|
|
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 DashboardHost, never the forged origin")
|
|
}
|
|
|
|
func TestOAuth2_Unbind_UnknownProviderRejected(t *testing.T) {
|
|
defer setupOAuth2Test(t)()
|
|
|
|
c, _ := newOAuth2Ctx(t)
|
|
c.Params = gin.Params{{Key: "provider", Value: "unknown-provider"}}
|
|
c.Set(model.CtxKeyAuthorizedUser, &model.User{Common: model.Common{ID: 1}})
|
|
|
|
_, err := unbindOauth2(c)
|
|
require.Error(t, err)
|
|
require.Contains(t, err.Error(), "provider not found")
|
|
}
|
|
|
|
func TestOAuth2_Unbind_BlocksLastBindWhenRejectPassword(t *testing.T) {
|
|
defer setupOAuth2Test(t)()
|
|
|
|
require.NoError(t, singleton.DB.Create(&model.Oauth2Bind{
|
|
UserID: 42,
|
|
Provider: "github",
|
|
OpenID: "openid-only-one",
|
|
}).Error)
|
|
|
|
c, _ := newOAuth2Ctx(t)
|
|
c.Params = gin.Params{{Key: "provider", Value: "github"}}
|
|
c.Set(model.CtxKeyAuthorizedUser, &model.User{
|
|
Common: model.Common{ID: 42},
|
|
RejectPassword: true,
|
|
})
|
|
|
|
_, err := unbindOauth2(c)
|
|
require.Error(t, err,
|
|
"user with reject_password=true must NOT be able to unbind their last OAuth2 provider (would lock them out)")
|
|
}
|
|
|
|
func TestOAuth2_Unbind_AllowsWhenPasswordLoginPossible(t *testing.T) {
|
|
defer setupOAuth2Test(t)()
|
|
|
|
require.NoError(t, singleton.DB.Create(&model.Oauth2Bind{
|
|
UserID: 42,
|
|
Provider: "github",
|
|
OpenID: "openid-1",
|
|
}).Error)
|
|
|
|
c, _ := newOAuth2Ctx(t)
|
|
c.Params = gin.Params{{Key: "provider", Value: "github"}}
|
|
c.Set(model.CtxKeyAuthorizedUser, &model.User{
|
|
Common: model.Common{ID: 42},
|
|
RejectPassword: false,
|
|
})
|
|
|
|
_, err := unbindOauth2(c)
|
|
require.NoError(t, err)
|
|
|
|
var cnt int64
|
|
require.NoError(t, singleton.DB.Model(&model.Oauth2Bind{}).
|
|
Where("user_id = ? AND provider = ?", 42, "github").Count(&cnt).Error)
|
|
require.Equal(t, int64(0), cnt, "binding must be deleted")
|
|
}
|
|
|
|
func TestOAuth2_Unbind_OnlyAffectsOwnBindings(t *testing.T) {
|
|
defer setupOAuth2Test(t)()
|
|
|
|
require.NoError(t, singleton.DB.Create(&model.Oauth2Bind{
|
|
UserID: 42, Provider: "github", OpenID: "mine",
|
|
}).Error)
|
|
require.NoError(t, singleton.DB.Create(&model.Oauth2Bind{
|
|
UserID: 999, Provider: "github", OpenID: "victim",
|
|
}).Error)
|
|
|
|
c, _ := newOAuth2Ctx(t)
|
|
c.Params = gin.Params{{Key: "provider", Value: "github"}}
|
|
c.Set(model.CtxKeyAuthorizedUser, &model.User{Common: model.Common{ID: 42}})
|
|
|
|
_, err := unbindOauth2(c)
|
|
require.NoError(t, err)
|
|
|
|
var victim model.Oauth2Bind
|
|
require.NoError(t, singleton.DB.
|
|
Where("user_id = ? AND provider = ?", 999, "github").
|
|
First(&victim).Error,
|
|
"another user's binding must not be touched")
|
|
require.Equal(t, "victim", victim.OpenID)
|
|
}
|