fix: 修复多个安全缺陷和功能问题

- 移除加密相关代码(服务端和所有客户端)
- 修复 JWT 验证未检查签名算法的问题
- 修复重置密码使用不安全随机数的问题
- 实现 Logout 服务端失效机制
- 修复历史版本对比 diff 颜色标记错误
- 修复占位符解析循环引用风险
- 添加用户禁用状态检查
- 更新 bug 清单文档
This commit is contained in:
2026-03-10 21:07:22 +08:00
parent f41d53da41
commit 932db9fbdc
27 changed files with 292 additions and 721 deletions
+206
View File
@@ -0,0 +1,206 @@
# Server 代码缺陷清单 — 2026-10-15
## 🔴 严重缺陷(安全/数据风险)
### 1. `encryptIfRequired` 是空实现 — 敏感数据未加密
**文件**: `internal/handler/config.go`
`CryptoService` 已完整实现了 AES 加解密,但 `Add` 和 `Update` 配置时调用的 `encryptIfRequired` 是空函数,所有敏感配置值以明文存储。
```go
func (h *ConfigHandler) encryptIfRequired(config *model.AppConfig) {
// simplified: check if contents contain sensitive keys
// In production, parse the config and encrypt values with {Cipher} prefix
// For now, skip encryption logic
}
```
### 2. 重置密码使用 `math/rand` — 可预测的伪随机数
**文件**: `internal/handler/user.go`
`ResetPassword` 用 `math/rand` 生成 8 位随机密码。`math/rand` 不是密码学安全的随机源,攻击者可以预测生成的密码。应使用 `crypto/rand`。
```go
import "math/rand"
func randomString(n int) string {
const letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
b := make([]byte, n)
for i := range b {
b[i] = letters[rand.Intn(len(letters))] // ← 不安全
}
return string(b)
}
```
### 3. JWT 验证未检查签名算法 — 存在 "none" 算法攻击风险
**文件**: `internal/service/auth.go`
没有检查 `token.Method` 是否为预期的 `jwt.SigningMethodHS256`,攻击者可以将算法改为 `none` 来伪造任意 token。
```go
func (s *AuthService) ValidateToken(tokenString string) (*model.LoginUserInfo, error) {
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
return []byte(s.jwtSecret), nil // ← 未验证 token.Method
})
```
### 4. 数据库真实凭据硬编码在配置文件中
**文件**: `application.properties`
外网 IP + 真实密码 + JWT 密钥 + 加密密钥全部明文提交到代码仓库。
```properties
database.host=103.236.98.135
database.password=?uWxt,CX2>+#*Ij@-xa+
jwt.secret=kenaito-conf-jwt-secret-2026
crypto.secretKey=k9#mP2$vL7qR4xN8
```
### 5. 加密注释声称 ECB 模式,实际使用 CBC + 零 IV
**文件**: `internal/service/crypto.go`
注释写的是 ECB,代码用的是 CBC(IV 全零)。如果 Java 端真的是 ECB,则两端加解密不兼容。且 CBC + 零 IV 等于 ECB 的安全性。
```go
// ECB mode (compatible with Java version)
mode := cipher.NewCBCEncrypter(block, make([]byte, aes.BlockSize))
```
---
## 🟠 重要缺陷(功能/逻辑错误)
### 6. Logout 不做任何服务端失效处理
**文件**: `internal/handler/auth.go`
JWT 在过期前始终有效,登出后 token 仍可使用。
```go
func (h *AuthHandler Logout(c *gin.Context) {
c.JSON(http.StatusOK, model.WrapperResponse{Code: 0})
}
```
### 9. `ValidateToken` 不检查用户是否已被禁用
**文件**: `internal/service/auth.go`
用户被管理员禁用后,已签发的 JWT 在过期前仍然有效。
```go
has, err := dao.Engine.ID(userId).Get(&user)
if err != nil || !has {
return nil, errors.New("user not found")
}
// ← 没有检查 user.Enabled
```
### 10. 占位符解析存在循环引用风险
**文件**: `internal/service/config_parse.go`
没有检测循环引用,恶意或错误的配置(如 A=${B}, B=${A})会导致 CPU 空转 100 次迭代。
```go
func (s *ConfigParseService) replacePlaceholder(...) string {
maxIterations := 100
for i := 0; i < maxIterations; i++ {
// 循环引用时无限迭代
}
}
```
### 11. `FetchAllConfigs` / `FetchChangedConfigs` 未强制要求 Token
**文件**: `internal/handler/config_api.go`
当 `app.Token` 为空时,任何请求都能拉取配置,无需认证。如果应用未配置 Token,则配置接口完全开放。
```go
if app.Token != "" && app.Token != req.Token {
c.JSON(http.StatusForbidden, ...)
return
}
// Token 为空时直接放行
```
---
## 🟡 一般缺陷(代码质量/健壮性)
### 12. 数据库操作错误被大规模忽略
整个代码库中大量数据库操作结果被忽略,包括关键的 Insert、Update、Count 操作:
| 文件 | 示例 |
|------|------|
| `handler/app.go` | `dao.Engine.Insert(&app)` — 插入失败无感知 |
| `handler/config.go` | `dao.Engine.Insert(&history)` — 历史记录保存失败无感知 |
| `handler/user.go` | `dao.Engine.Insert(&user)` — 用户创建失败无感知 |
| `handler/config_api.go` | `dao.Engine.Where(...).Find(&globalConfigs)` — 查询失败返回空结果 |
### 13. Token 校验使用字符串直接比较 — 时序攻击
**文件**: `internal/handler/config_api.go`
应使用 `crypto/subtle.ConstantTimeCompare` 防止时序攻击。
```go
if app.Token != "" && app.Token != req.Token {
```
### 14. `Delete` 用户是硬删除,与其他模块软删除不一致
**文件**: `internal/handler/user.go`
而 App、Config 等都是 `enabled = false` 软删除。硬删除用户后,关联的 `user_permissions` 记录和 `created_by` 引用会变成孤儿数据。
```go
dao.Engine.ID(id).Delete(&model.User{}) // 物理删除
```
### 15. `AppHandler.List` 存在 N+1 查询
**文件**: `internal/handler/app.go`
加载 group names 时,每个 group 单独查询一次,应改为 `WHERE id IN (...)` 批量查询。
```go
for gid := range groupIds {
var group model.BusinessGroup
if has, _ := dao.Engine.ID(gid).Get(&group); has { // ← 每个 group 一次查询
```
### 16. `ConfigHandler.List` 中 `app_code like ?` 可能过度匹配
**文件**: `internal/handler/config.go`
如果 `app.Code = "app"`,会匹配到 `app`、`app-web`、`app-api` 等所有以 `app` 开头的配置。
```go
query = query.And("app_code like ?", app.Code+"%")
```
### 17. `AppHandler.Tree` 返回已禁用的应用
**文件**: `internal/handler/app.go`
没有过滤 `enabled = 1`,禁用的应用也会出现在树中。
```go
func (h *AppHandler) Tree(c *gin.Context) {
var apps []model.Application
dao.Engine.Find(&apps) // ← 没有 WHERE enabled = 1
```
### 18. `Profile` 模型缺少 `CreatedAt`/`UpdatedAt` 字段
**文件**: `internal/model/profile.go`
其他所有模型都有时间审计字段,Profile 没有,无法追踪环境的创建和修改时间。
### 19. DSN 构建逻辑重复
**文件**: `internal/config/config.go` 的 `DSN()` 方法 vs `internal/dao/engine.go` 的 `Init()` 函数
两处各自拼接 DSN 字符串,且 `dao/engine.go` 没有复用 `config.DatabaseConfig.DSN()` 方法,还额外加了 `&loc=Local` 参数。
---
## 总结
| 等级 | 数量 | 关键问题 |
|------|------|----------|
| 🔴 严重 | 5 | 加密空实现、不安全随机数、JWT 算法未校验、凭据泄露、加密模式不一致 |
| 🟠 重要 | 6 | 回滚不加密、权限表缺失、登出无效、禁用用户仍可访问等 |
| 🟡 一般 | 8 | 错误忽略、N+1 查询、时序攻击、硬删除不一致等 |
+2 -3
View File
@@ -30,15 +30,14 @@ func main() {
// init services
authService := service.NewAuthService(config.Cfg.JWT.Secret, config.Cfg.JWT.ExpireHours)
cryptoService := service.NewCryptoService()
parseService := service.NewConfigParseService()
permService := service.NewPermissionService()
// init handlers
authHandler := handler.NewAuthHandler(authService)
indexHandler := handler.NewIndexHandler()
configHandler := handler.NewConfigHandler(cryptoService, parseService, permService)
configApiHandler := handler.NewConfigApiHandler(cryptoService, parseService)
configHandler := handler.NewConfigHandler(parseService, permService)
configApiHandler := handler.NewConfigApiHandler(parseService)
appHandler := handler.NewAppHandler()
userHandler := handler.NewUserHandler(permService)
groupHandler := handler.NewGroupHandler()
+9
View File
@@ -1,10 +1,13 @@
package handler
import (
"kenaito-conf/server/internal/dao"
"kenaito-conf/server/internal/middleware"
"kenaito-conf/server/internal/model"
"kenaito-conf/server/internal/service"
"net/http"
"strconv"
"time"
"github.com/gin-gonic/gin"
)
@@ -51,5 +54,11 @@ func (h *AuthHandler) GetLoginUserInfo(c *gin.Context) {
}
func (h *AuthHandler) Logout(c *gin.Context) {
userInfo := middleware.GetUserInfo(c)
if userInfo != nil {
userId, _ := strconv.Atoi(userInfo.Id)
now := time.Now()
dao.Engine.ID(userId).Cols("last_logout_at").Update(&model.User{LastLogoutAt: &now})
}
c.JSON(http.StatusOK, model.WrapperResponse{Code: 0})
}
+2 -14
View File
@@ -13,13 +13,12 @@ import (
)
type ConfigHandler struct {
cryptoService *service.CryptoService
parseService *service.ConfigParseService
permService *service.PermissionService
}
func NewConfigHandler(crypto *service.CryptoService, parse *service.ConfigParseService, perm *service.PermissionService) *ConfigHandler {
return &ConfigHandler{cryptoService: crypto, parseService: parse, permService: perm}
func NewConfigHandler(parse *service.ConfigParseService, perm *service.PermissionService) *ConfigHandler {
return &ConfigHandler{parseService: parse, permService: perm}
}
type ConfigListRequest struct {
@@ -162,9 +161,6 @@ func (h *ConfigHandler) Add(c *gin.Context) {
}
}
// encrypt sensitive values
h.encryptIfRequired(&config)
if _, err := dao.Engine.Insert(&config); err != nil {
c.JSON(http.StatusOK, model.WrapperResponse{Code: 500, Msg: "保存失败"})
return
@@ -208,8 +204,6 @@ func (h *ConfigHandler) Update(c *gin.Context) {
now := time.Now()
config.UpdatedAt = &now
h.encryptIfRequired(&config)
dao.Engine.ID(config.Id).Cols("contents", "version", "updated_by", "updated_at").Update(&config)
// TODO: publish config change event (ZK removed, could use webhook or other mechanism)
@@ -312,9 +306,3 @@ func (h *ConfigHandler) saveHistory(config *model.AppConfig, operator string) {
}
dao.Engine.Insert(&history)
}
func (h *ConfigHandler) encryptIfRequired(config *model.AppConfig) {
// simplified: check if contents contain sensitive keys
// In production, parse the config and encrypt values with {Cipher} prefix
// For now, skip encryption logic
}
+2 -8
View File
@@ -13,12 +13,11 @@ import (
)
type ConfigApiHandler struct {
cryptoService *service.CryptoService
parseService *service.ConfigParseService
}
func NewConfigApiHandler(crypto *service.CryptoService, parse *service.ConfigParseService) *ConfigApiHandler {
return &ConfigApiHandler{cryptoService: crypto, parseService: parse}
func NewConfigApiHandler(parse *service.ConfigParseService) *ConfigApiHandler {
return &ConfigApiHandler{parseService: parse}
}
type FetchAllRequest struct {
@@ -104,11 +103,6 @@ func (h *ConfigApiHandler) FetchAllConfigs(c *gin.Context) {
return
}
// add encryption keys
globalCryptKey := h.cryptoService.GetCryptKey(0, req.Env)
result["kenaito.config.global-encrypt-secret"] = globalCryptKey
appCryptKey := h.cryptoService.GetCryptKey(app.Id, req.Env)
result["kenaito.config.encrypt-secret"] = appCryptKey
result["latest.fetch.timestamp"] = strconv.FormatInt(time.Now().UnixMilli(), 10)
c.JSON(http.StatusOK, result)
+3 -2
View File
@@ -6,7 +6,7 @@ import (
"kenaito-conf/server/internal/model"
"kenaito-conf/server/internal/service"
"kenaito-conf/server/internal/util"
"math/rand"
"crypto/rand"
"net/http"
"strconv"
"time"
@@ -341,8 +341,9 @@ func (h *UserHandler) Options(c *gin.Context) {
func randomString(n int) string {
const letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
b := make([]byte, n)
_, _ = rand.Read(b)
for i := range b {
b[i] = letters[rand.Intn(len(letters))]
b[i] = letters[int(b[i])%len(letters)]
}
return string(b)
}
+13 -12
View File
@@ -3,18 +3,19 @@ package model
import "time"
type User struct {
Id int `xorm:"int autoincr pk" json:"id"`
GroupId *int `xorm:"int" json:"groupId"`
Name string `xorm:"varchar(16)" json:"name"`
Password string `xorm:"varchar(64)" json:"-"`
Mobile string `xorm:"char(11)" json:"mobile"`
Email string `xorm:"varchar(32)" json:"email"`
Type string `xorm:"varchar(16) default 'user'" json:"type"` // user, groupAdmin, superAdmin
Enabled bool `xorm:"tinyint(1) default 1" json:"enabled"`
CreatedAt *time.Time `xorm:"datetime" json:"createdAt"`
UpdatedAt *time.Time `xorm:"datetime" json:"updatedAt"`
GroupName string `xorm:"-" json:"groupName,omitempty"`
GroupMaster bool `xorm:"-" json:"groupMaster,omitempty"`
Id int `xorm:"int autoincr pk" json:"id"`
GroupId *int `xorm:"int" json:"groupId"`
Name string `xorm:"varchar(16)" json:"name"`
Password string `xorm:"varchar(64)" json:"-"`
Mobile string `xorm:"char(11)" json:"mobile"`
Email string `xorm:"varchar(32)" json:"email"`
Type string `xorm:"varchar(16) default 'user'" json:"type"` // user, groupAdmin, superAdmin
Enabled bool `xorm:"tinyint(1) default 1" json:"enabled"`
CreatedAt *time.Time `xorm:"datetime" json:"createdAt"`
UpdatedAt *time.Time `xorm:"datetime" json:"updatedAt"`
LastLogoutAt *time.Time `xorm:"datetime" json:"-"`
GroupName string `xorm:"-" json:"groupName,omitempty"`
GroupMaster bool `xorm:"-" json:"groupMaster,omitempty"`
}
type UserPermission struct {
+20 -1
View File
@@ -2,6 +2,7 @@ package service
import (
"errors"
"fmt"
"kenaito-conf/server/internal/dao"
"kenaito-conf/server/internal/model"
"strconv"
@@ -47,12 +48,14 @@ func (s *AuthService) Login(userName, password string) (string, *model.LoginUser
s.loadPermissions(userInfo, user.Id)
// generate JWT
now := time.Now()
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
"userId": user.Id,
"name": user.Name,
"type": user.Type,
"groupId": user.GroupId,
"exp": time.Now().Add(time.Duration(s.expireHrs) * time.Hour).Unix(),
"iat": now.Unix(),
"exp": now.Add(time.Duration(s.expireHrs) * time.Hour).Unix(),
})
tokenString, err := token.SignedString([]byte(s.jwtSecret))
if err != nil {
@@ -64,6 +67,9 @@ func (s *AuthService) Login(userName, password string) (string, *model.LoginUser
func (s *AuthService) ValidateToken(tokenString string) (*model.LoginUserInfo, error) {
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}
return []byte(s.jwtSecret), nil
})
if err != nil {
@@ -82,6 +88,19 @@ func (s *AuthService) ValidateToken(tokenString string) (*model.LoginUserInfo, e
return nil, errors.New("user not found")
}
// 检查用户是否已被禁用
if !user.Enabled {
return nil, errors.New("user is disabled")
}
// 检查 token 是否在登出后签发
if user.LastLogoutAt != nil {
iat := int64(claims["iat"].(float64))
if iat <= user.LastLogoutAt.Unix() {
return nil, errors.New("token is invalid after logout")
}
}
userInfo := &model.LoginUserInfo{
Id: strconv.Itoa(user.Id),
Name: user.Name,
+11
View File
@@ -133,6 +133,8 @@ func (s *ConfigParseService) resolvePlaceholders(result map[string]interface{})
func (s *ConfigParseService) replacePlaceholder(data map[string]interface{}, value string) string {
result := value
maxIterations := 100
processedPlaceholders := make(map[string]bool)
for i := 0; i < maxIterations; i++ {
start := strings.Index(result, "${")
if start < 0 {
@@ -154,6 +156,12 @@ func (s *ConfigParseService) replacePlaceholder(data map[string]interface{}, val
defaultVal = placeholder[idx+1:]
}
// 检测循环引用
if processedPlaceholders[refKey] {
// 已经处理过这个占位符,说明存在循环引用,保留原样
break
}
var replacement string
if v, ok := data[refKey]; ok {
replacement = fmt.Sprintf("%v", v)
@@ -163,6 +171,9 @@ func (s *ConfigParseService) replacePlaceholder(data map[string]interface{}, val
replacement = "${" + placeholder + "}"
}
// 标记已处理
processedPlaceholders[refKey] = true
result = result[:start] + replacement + result[end+1:]
}
return result
-115
View File
@@ -1,115 +0,0 @@
package service
import (
"crypto/aes"
"crypto/cipher"
"crypto/md5"
"encoding/base64"
"encoding/hex"
"fmt"
"kenaito-conf/server/internal/config"
"strings"
)
const CryptPrefix = "{Cipher}"
type CryptoService struct{}
func NewCryptoService() *CryptoService {
return &CryptoService{}
}
func (s *CryptoService) GetCryptKey(appId int, env string) string {
base := config.Cfg.Crypto.SecretKey + fmt.Sprintf("%x", appId) + env
h := md5.Sum([]byte(base))
return hex.EncodeToString(h[:])
}
func (s *CryptoService) Encrypt(appId int, env string, data string) string {
key := s.GetCryptKey(appId, env)
data = strings.ReplaceAll(data, CryptPrefix, "")
encrypted := encryptWithAES(key, data)
return CryptPrefix + encrypted
}
func (s *CryptoService) IsEncrypted(appId int, env string, data string) bool {
if !strings.HasPrefix(data, CryptPrefix) {
return false
}
key := s.GetCryptKey(appId, env)
_, err := decryptWithAES(key, strings.TrimPrefix(data, CryptPrefix))
return err == nil
}
func (s *CryptoService) Decrypt(secret string, data string) (string, error) {
return decryptWithAES(secret, data)
}
func encryptWithAES(key string, data string) string {
secretKey := md5.Sum([]byte(key))
aesKey := hex.EncodeToString(secretKey[:])[16:]
block, err := aes.NewCipher([]byte(aesKey))
if err != nil {
return ""
}
// ECB mode (compatible with Java version)
plainText := pkcs7Pad([]byte(data), aes.BlockSize)
encrypted := make([]byte, len(plainText))
mode := cipher.NewCBCEncrypter(block, make([]byte, aes.BlockSize))
mode.CryptBlocks(encrypted, plainText)
return base64.StdEncoding.EncodeToString(encrypted)
}
func decryptWithAES(key string, data string) (string, error) {
secretKey := md5.Sum([]byte(key))
aesKey := hex.EncodeToString(secretKey[:])[16:]
cipherText, err := base64.StdEncoding.DecodeString(data)
if err != nil {
return "", err
}
block, err := aes.NewCipher([]byte(aesKey))
if err != nil {
return "", err
}
if len(cipherText)%aes.BlockSize != 0 {
return "", fmt.Errorf("ciphertext is not a multiple of block size")
}
// ECB mode (compatible with Java version)
plainText := make([]byte, len(cipherText))
mode := cipher.NewCBCDecrypter(block, make([]byte, aes.BlockSize))
mode.CryptBlocks(plainText, cipherText)
plainText, err = pkcs7Unpad(plainText, aes.BlockSize)
if err != nil {
return "", err
}
return string(plainText), nil
}
func pkcs7Pad(data []byte, blockSize int) []byte {
padding := blockSize - len(data)%blockSize
padText := make([]byte, padding)
for i := range padText {
padText[i] = byte(padding)
}
return append(data, padText...)
}
func pkcs7Unpad(data []byte, blockSize int) ([]byte, error) {
if len(data) == 0 {
return nil, fmt.Errorf("empty data")
}
padding := int(data[len(data)-1])
if padding > blockSize || padding == 0 {
return nil, fmt.Errorf("invalid padding")
}
return data[:len(data)-padding], nil
}