mirror of
https://github.com/Buriburizaem0n/nezha_domains.git
synced 2026-11-04 16:40:21 +00:00
227 lines
8.0 KiB
Go
227 lines
8.0 KiB
Go
package controller
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"log"
|
|
"testing"
|
|
"time"
|
|
|
|
jwt "github.com/appleboy/gin-jwt/v2"
|
|
"github.com/stretchr/testify/require"
|
|
"gorm.io/driver/sqlite"
|
|
"gorm.io/gorm"
|
|
|
|
"github.com/nezhahq/nezha/model"
|
|
"github.com/nezhahq/nezha/service/singleton"
|
|
)
|
|
|
|
func setupDashboardCredentialTest(t *testing.T) {
|
|
t.Helper()
|
|
originalConf, originalDB, originalParser := singleton.Conf, singleton.DB, dashboardCredentialJWTParser
|
|
t.Cleanup(func() {
|
|
singleton.Conf, singleton.DB, dashboardCredentialJWTParser = originalConf, originalDB, originalParser
|
|
})
|
|
|
|
singleton.Conf = &singleton.ConfigClass{Config: &model.Config{}}
|
|
singleton.Conf.JWTSecretKey = "dashboard-credential-classification-test-key"
|
|
singleton.Conf.JWTTimeout = 1
|
|
|
|
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
|
require.NoError(t, err)
|
|
sqlDB, err := db.DB()
|
|
require.NoError(t, err)
|
|
t.Cleanup(func() { _ = sqlDB.Close() })
|
|
require.NoError(t, db.AutoMigrate(&model.APIToken{}))
|
|
singleton.DB = db
|
|
|
|
// Same construction path as routers(): jwt.New(initParams()).
|
|
authMiddleware, err := jwt.New(initParams())
|
|
require.NoError(t, err)
|
|
dashboardCredentialJWTParser = newDashboardCredentialJWTParser(authMiddleware)
|
|
}
|
|
|
|
// mintDashboardCredentialJWT signs a JWT with the dashboard key through
|
|
// initParams(), the same key source the production verifier uses. A negative
|
|
// timeout mints an already-expired token.
|
|
func mintDashboardCredentialJWT(t *testing.T, timeout time.Duration) string {
|
|
t.Helper()
|
|
params := initParams()
|
|
params.Timeout = timeout
|
|
mw, err := jwt.New(params)
|
|
require.NoError(t, err)
|
|
token, _, err := mw.TokenGenerator(nil)
|
|
require.NoError(t, err)
|
|
return token
|
|
}
|
|
|
|
func mintForeignJWT(t *testing.T) string {
|
|
t.Helper()
|
|
params := initParams()
|
|
params.Key = []byte("foreign-signing-key-not-held-by-the-dashboard")
|
|
mw, err := jwt.New(params)
|
|
require.NoError(t, err)
|
|
token, _, err := mw.TokenGenerator(nil)
|
|
require.NoError(t, err)
|
|
return token
|
|
}
|
|
|
|
func TestIsDashboardCredentialClassification(t *testing.T) {
|
|
setupDashboardCredentialTest(t)
|
|
|
|
dashboardJWT := mintDashboardCredentialJWT(t, time.Hour)
|
|
expiredDashboardJWT := mintDashboardCredentialJWT(t, -time.Hour)
|
|
foreignJWT := mintForeignJWT(t)
|
|
|
|
dashboardPAT := model.APITokenPrefix + "dashboard-credential-pat"
|
|
require.NoError(t, singleton.DB.Create(&model.APIToken{
|
|
UserID: 1,
|
|
Name: "nat-classification",
|
|
TokenHash: model.HashAPIToken(dashboardPAT),
|
|
}).Error)
|
|
|
|
t.Run("dashboard signed jwt is a dashboard credential", func(t *testing.T) {
|
|
require.True(t, IsDashboardCredential("Bearer "+dashboardJWT))
|
|
})
|
|
|
|
t.Run("expired dashboard signed jwt is still a dashboard credential", func(t *testing.T) {
|
|
// Signature verification ignores exp on purpose: an expired token is
|
|
// still proof the dashboard issued it.
|
|
require.True(t, IsDashboardCredential("Bearer "+expiredDashboardJWT))
|
|
})
|
|
|
|
t.Run("dashboard api token is a dashboard credential", func(t *testing.T) {
|
|
require.True(t, IsDashboardCredential("Bearer "+dashboardPAT))
|
|
})
|
|
|
|
t.Run("foreign signed jwt is not a dashboard credential", func(t *testing.T) {
|
|
require.False(t, IsDashboardCredential("Bearer "+foreignJWT))
|
|
})
|
|
|
|
t.Run("unknown api token is not a dashboard credential", func(t *testing.T) {
|
|
require.False(t, IsDashboardCredential("Bearer "+model.APITokenPrefix+"unknown-to-the-dashboard"))
|
|
})
|
|
|
|
t.Run("random string is not a dashboard credential", func(t *testing.T) {
|
|
require.False(t, IsDashboardCredential("Bearer random-not-a-token"))
|
|
})
|
|
|
|
t.Run("non bearer scheme is not a dashboard credential", func(t *testing.T) {
|
|
require.False(t, IsDashboardCredential("Basic "+dashboardJWT))
|
|
// Scheme matching is case-sensitive, as in both dashboard auth middlewares.
|
|
require.False(t, IsDashboardCredential("bearer "+dashboardJWT))
|
|
})
|
|
|
|
t.Run("empty or missing header is not a dashboard credential", func(t *testing.T) {
|
|
require.False(t, IsDashboardCredential(""))
|
|
require.False(t, IsDashboardCredential("Bearer "))
|
|
})
|
|
}
|
|
|
|
// TestIsDashboardCredentialValueClassification pins the scheme-less classifier
|
|
// that backs IsDashboardCredential and drives the ?token= query parameter and
|
|
// nz-jwt cookie channels at NAT ingress (see IsDashboardCredentialValue).
|
|
func TestIsDashboardCredentialValueClassification(t *testing.T) {
|
|
setupDashboardCredentialTest(t)
|
|
|
|
dashboardJWT := mintDashboardCredentialJWT(t, time.Hour)
|
|
expiredDashboardJWT := mintDashboardCredentialJWT(t, -time.Hour)
|
|
foreignJWT := mintForeignJWT(t)
|
|
|
|
dashboardPAT := model.APITokenPrefix + "dashboard-credential-value-pat"
|
|
require.NoError(t, singleton.DB.Create(&model.APIToken{
|
|
UserID: 1,
|
|
Name: "nat-value-classification",
|
|
TokenHash: model.HashAPIToken(dashboardPAT),
|
|
}).Error)
|
|
|
|
t.Run("dashboard signed jwt is a dashboard credential", func(t *testing.T) {
|
|
require.True(t, IsDashboardCredentialValue(dashboardJWT))
|
|
})
|
|
t.Run("expired dashboard signed jwt is still a dashboard credential", func(t *testing.T) {
|
|
require.True(t, IsDashboardCredentialValue(expiredDashboardJWT))
|
|
})
|
|
t.Run("dashboard api token is a dashboard credential", func(t *testing.T) {
|
|
require.True(t, IsDashboardCredentialValue(dashboardPAT))
|
|
})
|
|
t.Run("foreign signed jwt is not a dashboard credential", func(t *testing.T) {
|
|
require.False(t, IsDashboardCredentialValue(foreignJWT))
|
|
})
|
|
t.Run("unknown api token is not a dashboard credential", func(t *testing.T) {
|
|
require.False(t, IsDashboardCredentialValue(model.APITokenPrefix+"unknown-to-the-dashboard"))
|
|
})
|
|
t.Run("random string is not a dashboard credential", func(t *testing.T) {
|
|
require.False(t, IsDashboardCredentialValue("random-not-a-token"))
|
|
})
|
|
t.Run("empty value is not a dashboard credential", func(t *testing.T) {
|
|
require.False(t, IsDashboardCredentialValue(""))
|
|
})
|
|
t.Run("bearer scheme is not part of the value contract", func(t *testing.T) {
|
|
// The query and cookie channels carry the bare value; a value that
|
|
// itself starts with "Bearer " is not something the panel issues and
|
|
// fails signature verification.
|
|
require.False(t, IsDashboardCredentialValue("Bearer "+dashboardJWT))
|
|
})
|
|
}
|
|
|
|
func TestClassifyDashboardCredentialValuesBatchesPATLookup(t *testing.T) {
|
|
setupDashboardCredentialTest(t)
|
|
|
|
dashboardPAT := model.APITokenPrefix + "dashboard-batch-pat"
|
|
require.NoError(t, singleton.DB.Create(&model.APIToken{
|
|
UserID: 1,
|
|
Name: "nat-batch-classification",
|
|
TokenHash: model.HashAPIToken(dashboardPAT),
|
|
}).Error)
|
|
|
|
queryCount := 0
|
|
const callbackName = "test:count-dashboard-credential-batch-query"
|
|
require.NoError(t, singleton.DB.Callback().Query().Before("gorm:query").Register(callbackName, func(*gorm.DB) {
|
|
queryCount++
|
|
}))
|
|
t.Cleanup(func() { singleton.DB.Callback().Query().Remove(callbackName) })
|
|
|
|
values := []string{
|
|
dashboardPAT,
|
|
model.APITokenPrefix + "unknown-one",
|
|
dashboardPAT,
|
|
model.APITokenPrefix + "unknown-two",
|
|
}
|
|
classified := ClassifyDashboardCredentialValues(context.Background(), values)
|
|
|
|
require.Equal(t, []bool{true, false, true, false}, classified)
|
|
require.Equal(t, 1, queryCount, "all PAT hashes in one request must use one database query")
|
|
}
|
|
|
|
func TestIsDashboardCredentialFailsClosedWhenParserUnavailable(t *testing.T) {
|
|
setupDashboardCredentialTest(t)
|
|
dashboardCredentialJWTParser = nil
|
|
|
|
token := mintDashboardCredentialJWT(t, time.Hour)
|
|
require.True(t, IsDashboardCredential("Bearer "+token))
|
|
}
|
|
|
|
func TestIsDashboardCredentialFailsClosedWhenAPITokenLookupUnavailable(t *testing.T) {
|
|
setupDashboardCredentialTest(t)
|
|
singleton.DB = nil
|
|
|
|
require.True(t, IsDashboardCredential("Bearer "+model.APITokenPrefix+"unresolvable"))
|
|
}
|
|
|
|
func TestIsDashboardCredentialFailsClosedOnDatabaseError(t *testing.T) {
|
|
setupDashboardCredentialTest(t)
|
|
sqlDB, err := singleton.DB.DB()
|
|
require.NoError(t, err)
|
|
require.NoError(t, sqlDB.Close())
|
|
|
|
var logs bytes.Buffer
|
|
originalOutput := log.Writer()
|
|
log.SetOutput(&logs)
|
|
t.Cleanup(func() { log.SetOutput(originalOutput) })
|
|
|
|
plaintext := model.APITokenPrefix + "unresolvable-after-db-failure"
|
|
require.True(t, IsDashboardCredential("Bearer "+plaintext))
|
|
// The credential content itself must never reach the log.
|
|
require.NotContains(t, logs.String(), plaintext)
|
|
}
|