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.
This commit is contained in:
Jasper Van
2026-06-06 02:17:01 -04:00
committed by GitHub
parent 33eec01e97
commit 8662db8d4b
8 changed files with 161 additions and 15 deletions
+11 -1
View File
@@ -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)
}
}
}
}
+11 -1
View File
@@ -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 |
| mssql | sqlserver://zpan:LoremIpsum86@localhost:9930?database=zpan |
## security
这里定义了ZPan的安全配置
```yaml
security:
jwt_secret: your-random-secret
```
### jwt_secret
用于签发和校验登录令牌。安装和升级时会自动生成随机值,请不要与其他实例共用。
+11 -1
View File
@@ -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 addresseg:no-reply@saltbo.fun
SenderegZpan
### password
Sending password
Sending password
+3 -5
View File
@@ -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")
+1 -3
View File
@@ -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 {
+5 -3
View File
@@ -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()
+55 -1
View File
@@ -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))
}
+64
View File
@@ -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)
}