From 8662db8d4b06057631faf3deba4132e66705e947 Mon Sep 17 00:00:00 2001 From: Jasper Van Date: Sat, 6 Jun 2026 02:17:01 -0400 Subject: [PATCH] fix: use configured JWT secret in v1 Fixes CVE-2025-7453 / GHSA-2hfh-94w5-wxvf by replacing the v1 hardcoded JWT secret with a generated per-installation config secret. --- cmd/root.go | 12 +++++- docs/config.md | 12 +++++- docs/en/config.md | 12 +++++- internal/app/api/share.go | 8 ++-- internal/app/api/storage.go | 4 +- internal/app/api/system.go | 8 ++-- internal/app/service/token.go | 56 +++++++++++++++++++++++++- internal/app/service/token_test.go | 64 ++++++++++++++++++++++++++++++ 8 files changed, 161 insertions(+), 15 deletions(-) create mode 100644 internal/app/service/token_test.go diff --git a/cmd/root.go b/cmd/root.go index a1222cc6..6e2a3dc8 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -25,8 +25,8 @@ import ( "fmt" "os" + "github.com/saltbo/zpan/internal/app/service" "github.com/spf13/cobra" - "github.com/spf13/viper" ) @@ -73,5 +73,15 @@ func initConfig() { if err := viper.ReadInConfig(); err == nil { fmt.Println("Using config file:", viper.ConfigFileUsed()) viper.Set("installed", true) + if viper.GetString("security.jwt_secret") == "" { + if err := service.EnsureJWTSecret(); err != nil { + fmt.Println(err) + os.Exit(1) + } + if err := viper.WriteConfig(); err != nil { + fmt.Println(err) + os.Exit(1) + } + } } } diff --git a/docs/config.md b/docs/config.md index 75b5a1fa..8f82acec 100644 --- a/docs/config.md +++ b/docs/config.md @@ -29,4 +29,14 @@ database: | sqlite3 | zpan.db | | mysql | user:pass@tcp(127.0.0.1:3306)/zpan?charset=utf8mb4&parseTime=True&loc=Local | | postgres | user=zpan password=zpan dbname=zpan port=9920 sslmode=disable TimeZone=Asia/Shanghai | -| mssql | sqlserver://zpan:LoremIpsum86@localhost:9930?database=zpan | \ No newline at end of file +| mssql | sqlserver://zpan:LoremIpsum86@localhost:9930?database=zpan | + +## security +这里定义了ZPan的安全配置 +```yaml +security: + jwt_secret: your-random-secret +``` + +### jwt_secret +用于签发和校验登录令牌。安装和升级时会自动生成随机值,请不要与其他实例共用。 diff --git a/docs/en/config.md b/docs/en/config.md index d6598486..70236f5c 100644 --- a/docs/en/config.md +++ b/docs/en/config.md @@ -79,6 +79,16 @@ The dsn corresponding to different drivers is also different, here we give the d | postgres | user=zpan password=zpan dbname=zpan port=9920 sslmode=disable TimeZone=Asia/Shanghai | | mssql | sqlserver://zpan:LoremIpsum86@localhost:9930?database=zpan | +# security +Configure security settings +```yaml +security: + jwt_secret: your-random-secret +``` + +### jwt_secret +Used to sign and verify login tokens. ZPan generates a random value during installation and upgrade. Do not share it across instances. + # provider Currently we support all S3-based cloud storage platforms, such as Alibaba Cloud OSS, Tencent Cloud COS, Qiniu Cloud KODO. ```yaml @@ -131,4 +141,4 @@ Mailing address,eg:no-reply@saltbo.fun Sender,eg:Zpan ### password -Sending password \ No newline at end of file +Sending password diff --git a/internal/app/api/share.go b/internal/app/api/share.go index 560e9d88..a13b584a 100644 --- a/internal/app/api/share.go +++ b/internal/app/api/share.go @@ -8,12 +8,12 @@ import ( "github.com/dgrijalva/jwt-go" "github.com/gin-gonic/gin" "github.com/saltbo/gopkg/ginutil" - "github.com/saltbo/gopkg/jwtutil" "github.com/saltbo/gopkg/strutil" "gorm.io/gorm" "github.com/saltbo/zpan/internal/app/dao" "github.com/saltbo/zpan/internal/app/model" + "github.com/saltbo/zpan/internal/app/service" "github.com/saltbo/zpan/internal/pkg/authed" "github.com/saltbo/zpan/internal/pkg/bind" ) @@ -21,8 +21,6 @@ import ( const ShareCookieTokenKey = "share-token" type ShareResource struct { - jwtutil.JWTUtil - dShare *dao.Share dMatter *dao.Matter } @@ -170,7 +168,7 @@ func (rs *ShareResource) draw(c *gin.Context) { NotBefore: time.Now().Unix(), Subject: share.Alias, } - token, err := rs.JWTUtil.Issue(claims) + token, err := service.NewJWTUtil().Issue(claims) if err != nil { ginutil.JSONServerError(c, err) return @@ -250,7 +248,7 @@ func (rs *ShareResource) shareTokenVerify(c *gin.Context, share *model.Share) er return err } - if token, err := rs.JWTUtil.Parse(tokenStr, &jwt.StandardClaims{}); err != nil { + if token, err := service.NewJWTUtil().Parse(tokenStr, &jwt.StandardClaims{}); err != nil { return err } else if token.Claims.(*jwt.StandardClaims).Subject != share.Alias { return fmt.Errorf("unmatched token") diff --git a/internal/app/api/storage.go b/internal/app/api/storage.go index 1f5439a5..a1763b8e 100644 --- a/internal/app/api/storage.go +++ b/internal/app/api/storage.go @@ -3,15 +3,13 @@ package api import ( "github.com/gin-gonic/gin" "github.com/saltbo/gopkg/ginutil" - "github.com/saltbo/gopkg/jwtutil" "github.com/saltbo/zpan/internal/app/dao" - "github.com/saltbo/zpan/internal/pkg/bind" "github.com/saltbo/zpan/internal/app/service" + "github.com/saltbo/zpan/internal/pkg/bind" ) type Storage struct { - jwtutil.JWTUtil } func NewStorageResource() *Storage { diff --git a/internal/app/api/system.go b/internal/app/api/system.go index d019080f..bcc94109 100644 --- a/internal/app/api/system.go +++ b/internal/app/api/system.go @@ -5,7 +5,6 @@ import ( "github.com/gin-gonic/gin" "github.com/saltbo/gopkg/ginutil" - "github.com/saltbo/gopkg/jwtutil" "github.com/saltbo/gopkg/strutil" "github.com/spf13/viper" @@ -18,8 +17,6 @@ import ( ) type Option struct { - jwtutil.JWTUtil - sOption *service.Option } @@ -63,6 +60,11 @@ func (rs *Option) setupDatabase(c *gin.Context) { return } + if err := service.EnsureJWTSecret(); err != nil { + ginutil.JSONServerError(c, err) + return + } + viper.Set("database.driver", p["driver"]) viper.Set("database.dsn", p["dsn"]) cfgFile := viper.ConfigFileUsed() diff --git a/internal/app/service/token.go b/internal/app/service/token.go index b6aae052..1d5873b6 100644 --- a/internal/app/service/token.go +++ b/internal/app/service/token.go @@ -1,22 +1,76 @@ package service import ( + "crypto/rand" + "encoding/base64" "fmt" "strconv" "time" "github.com/dgrijalva/jwt-go" "github.com/saltbo/gopkg/jwtutil" + "github.com/spf13/viper" ) +const jwtSecretConfigKey = "security.jwt_secret" + +var temporaryJWTSecret string + type Token struct { } func NewToken() *Token { - jwtutil.Init("123") + jwtutil.Init(JWTSecret()) return &Token{} } +func NewJWTUtil() *jwtutil.JWTUtil { + return jwtutil.New(JWTSecret()) +} + +func EnsureJWTSecret() error { + if viper.GetString(jwtSecretConfigKey) != "" { + return nil + } + + secret, err := generateJWTSecret() + if err != nil { + return err + } + + viper.Set(jwtSecretConfigKey, secret) + return nil +} + +func JWTSecret() string { + if secret := viper.GetString(jwtSecretConfigKey); secret != "" { + return secret + } + + if !viper.IsSet("installed") { + if temporaryJWTSecret == "" { + secret, err := generateJWTSecret() + if err != nil { + panic(fmt.Sprintf("generate temporary jwt secret failed: %s", err)) + } + temporaryJWTSecret = secret + } + + return temporaryJWTSecret + } + + panic(fmt.Sprintf("missing required config %q", jwtSecretConfigKey)) +} + +func generateJWTSecret() (string, error) { + buf := make([]byte, 32) + if _, err := rand.Read(buf); err != nil { + return "", err + } + + return base64.RawURLEncoding.EncodeToString(buf), nil +} + func (s *Token) Create(uid string, ttl int, roles ...string) (string, error) { return jwtutil.Issue(NewRoleClaims(uid, ttl, roles)) } diff --git a/internal/app/service/token_test.go b/internal/app/service/token_test.go new file mode 100644 index 00000000..750d16f6 --- /dev/null +++ b/internal/app/service/token_test.go @@ -0,0 +1,64 @@ +package service + +import ( + "testing" + + "github.com/dgrijalva/jwt-go" + "github.com/spf13/viper" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func resetTokenConfig(t *testing.T) { + t.Helper() + + viper.Reset() + temporaryJWTSecret = "" + t.Cleanup(func() { + viper.Reset() + temporaryJWTSecret = "" + }) +} + +func TestTokenRejectsHardcodedSecret(t *testing.T) { + resetTokenConfig(t) + viper.Set("installed", true) + viper.Set(jwtSecretConfigKey, "configured-secret") + + tokenService := NewToken() + forgedToken, err := jwt.NewWithClaims( + jwt.SigningMethodHS512, + NewRoleClaims("1", 3600, []string{"admin"}), + ).SignedString([]byte("123")) + require.NoError(t, err) + + _, err = tokenService.Verify(forgedToken) + assert.Error(t, err) + + validToken, err := tokenService.Create("1", 3600, "admin") + require.NoError(t, err) + + claims, err := tokenService.Verify(validToken) + require.NoError(t, err) + assert.Equal(t, "1", claims.Subject) + assert.Equal(t, []string{"admin"}, claims.Roles) +} + +func TestTokenRequiresConfiguredSecretAfterInstall(t *testing.T) { + resetTokenConfig(t) + viper.Set("installed", true) + + assert.Panics(t, func() { + NewToken() + }) +} + +func TestEnsureJWTSecretGeneratesPersistentConfig(t *testing.T) { + resetTokenConfig(t) + + require.NoError(t, EnsureJWTSecret()) + + secret := viper.GetString(jwtSecretConfigKey) + assert.NotEmpty(t, secret) + assert.NotEqual(t, "123", secret) +}