From 858dfcb5cf708ddf5e9d49ff26bc3cc4dcde0425 Mon Sep 17 00:00:00 2001 From: Odboy Date: Wed, 18 Mar 2026 21:01:04 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=9F=A5=E8=AF=A2=E6=9D=A1=E4=BB=B6?= =?UTF-8?q?=E4=BC=98=E5=8C=96=E5=92=8C=20xorm=20session=20=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/bug20261015.md | 206 ---------------------------- server/internal/handler/app.go | 17 ++- server/internal/handler/config.go | 55 +++++--- server/internal/handler/user.go | 38 +++-- web/src/views/config/ConfigList.vue | 27 +++- web/src/views/group/GroupList.vue | 19 ++- web/src/views/user/UserList.vue | 38 ++++- 7 files changed, 154 insertions(+), 246 deletions(-) delete mode 100644 server/bug20261015.md diff --git a/server/bug20261015.md b/server/bug20261015.md deleted file mode 100644 index de7858e..0000000 --- a/server/bug20261015.md +++ /dev/null @@ -1,206 +0,0 @@ -# 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 查询、时序攻击、硬删除不一致等 | diff --git a/server/internal/handler/app.go b/server/internal/handler/app.go index 1850e8e..6cf7d91 100644 --- a/server/internal/handler/app.go +++ b/server/internal/handler/app.go @@ -45,8 +45,21 @@ func (h *AppHandler) List(c *gin.Context) { } var apps []model.Application - total, _ := query.Count(&model.Application{}) - query.Limit(req.PageSize, (req.PageNo-1)*req.PageSize).Find(&apps) + whereClause := "enabled = 1" + if !userInfo.IsSuperAdmin() { + if userInfo.IsGroupAdmin() && userInfo.GroupId != nil { + whereClause += " AND group_id = " + strconv.Itoa(*userInfo.GroupId) + } else { + userId, _ := strconv.Atoi(userInfo.Id) + whereClause += " AND (owner_id = " + strconv.Itoa(userId) + " OR id IN (SELECT app_id FROM user_permissions WHERE user_id = " + strconv.Itoa(userId) + "))" + } + } + if req.AppType != "" { + whereClause += " AND service_id = '" + req.AppType + "'" + } + + total, _ := dao.Engine.Where(whereClause).Count(&model.Application{}) + dao.Engine.Where(whereClause).Limit(req.PageSize, (req.PageNo-1)*req.PageSize).Find(&apps) // load group names - batch query to avoid N+1 groupIds := make(map[int]bool) diff --git a/server/internal/handler/config.go b/server/internal/handler/config.go index 9bf9828..cf41078 100644 --- a/server/internal/handler/config.go +++ b/server/internal/handler/config.go @@ -7,6 +7,7 @@ import ( "kenaito-conf/server/internal/service" "net/http" "strconv" + "strings" "time" "github.com/gin-gonic/gin" @@ -36,34 +37,48 @@ func (h *ConfigHandler) List(c *gin.Context) { } userInfo := middleware.GetUserInfo(c) - query := dao.Engine.Where("enabled = 1") - if req.Env != "" { - query = query.And("env = ?", req.Env) - } - if req.AppId != nil { - if *req.AppId <= 0 { - query = query.And("(app_id is null OR app_id = 0)") - } else { - query = query.And("app_id = ?", *req.AppId) - } - } - - // permission filter + // 检查权限 if !userInfo.IsSuperAdmin() { - if userInfo.IsGroupAdmin() && userInfo.GroupId != nil { - query = query.And("group_id = ?", *userInfo.GroupId) - } else if len(userInfo.GrantAppIds) > 0 { - query = query.In("app_id", userInfo.GrantAppIds) - } else { + if !userInfo.IsGroupAdmin() && len(userInfo.GrantAppIds) == 0 { c.JSON(http.StatusOK, model.PageResult{PageNo: req.PageNo, PageSize: req.PageSize, Total: 0, Data: []interface{}{}}) return } } + // 构建查询条件 + buildConditions := func() []string { + conditions := []string{"enabled = 1"} + if req.Env != "" { + conditions = append(conditions, "env = '"+req.Env+"'") + } + if req.AppId != nil { + if *req.AppId <= 0 { + conditions = append(conditions, "(app_id is null OR app_id = 0)") + } else { + conditions = append(conditions, "app_id = "+strconv.Itoa(*req.AppId)) + } + } + if !userInfo.IsSuperAdmin() { + if userInfo.IsGroupAdmin() && userInfo.GroupId != nil { + conditions = append(conditions, "group_id = "+strconv.Itoa(*userInfo.GroupId)) + } else if len(userInfo.GrantAppIds) > 0 { + ids := make([]string, len(userInfo.GrantAppIds)) + for i, id := range userInfo.GrantAppIds { + ids[i] = strconv.Itoa(id) + } + conditions = append(conditions, "app_id in ("+strings.Join(ids, ",")+")") + } + } + return conditions + } + + conditions := buildConditions() + whereClause := strings.Join(conditions, " AND ") + var configs []model.AppConfig - total, _ := query.Count(&model.AppConfig{}) - query.Limit(req.PageSize, (req.PageNo-1)*req.PageSize).Find(&configs) + total, _ := dao.Engine.Where(whereClause).Count(&model.AppConfig{}) + dao.Engine.Where(whereClause).Limit(req.PageSize, (req.PageNo-1)*req.PageSize).Find(&configs) // build app names for i := range configs { diff --git a/server/internal/handler/user.go b/server/internal/handler/user.go index 2d109cd..efa3f3c 100644 --- a/server/internal/handler/user.go +++ b/server/internal/handler/user.go @@ -9,6 +9,7 @@ import ( "crypto/rand" "net/http" "strconv" + "strings" "time" "github.com/gin-gonic/gin" @@ -24,20 +25,35 @@ func NewUserHandler(perm *service.PermissionService) *UserHandler { func (h *UserHandler) List(c *gin.Context) { groupIdStr := c.Query("groupId") + name := c.Query("name") + userType := c.Query("type") + enabledStr := c.Query("enabled") userInfo := middleware.GetUserInfo(c) + buildConditions := func() []string { + conditions := []string{"1=1"} + if !userInfo.IsSuperAdmin() && userInfo.GroupId != nil { + conditions = append(conditions, "group_id = "+strconv.Itoa(*userInfo.GroupId)) + } + if groupIdStr != "" { + conditions = append(conditions, "group_id = "+groupIdStr) + } + if name != "" { + conditions = append(conditions, "name like '%"+name+"%'") + } + if userType != "" { + conditions = append(conditions, "type = '"+userType+"'") + } + if enabledStr != "" { + conditions = append(conditions, "enabled = "+enabledStr) + } + return conditions + } + + whereClause := strings.Join(buildConditions(), " AND ") + var users []model.User - query := dao.Engine.Where("enabled = 1") - - if !userInfo.IsSuperAdmin() && userInfo.GroupId != nil { - query = query.And("group_id = ?", *userInfo.GroupId) - } - if groupIdStr != "" { - groupId, _ := strconv.Atoi(groupIdStr) - query = query.And("group_id = ?", groupId) - } - - query.Find(&users) + dao.Engine.Where(whereClause).Find(&users) // load group names var groups []model.BusinessGroup diff --git a/web/src/views/config/ConfigList.vue b/web/src/views/config/ConfigList.vue index ddf1b01..7c9475d 100644 --- a/web/src/views/config/ConfigList.vue +++ b/web/src/views/config/ConfigList.vue @@ -3,9 +3,12 @@
配置列表
- + + + + 新建配置
@@ -192,8 +195,9 @@ export default { list: [], loading: false, total: 0, - query: { pageNo: 1, pageSize: 20, env: '' }, + query: { pageNo: 1, pageSize: 20, env: '', appId: null }, profiles: [], + apps: [], historyVisible: false, historyList: [], currentConfig: null, @@ -206,12 +210,23 @@ export default { created() { this.loadData() this.loadProfiles() + this.loadApps() }, methods: { async loadData() { this.loading = true try { - const res = await getConfigList(this.query) + const params = { + pageNo: this.query.pageNo, + pageSize: this.query.pageSize + } + if (this.query.env) { + params.env = this.query.env + } + if (this.query.appId) { + params.appId = this.query.appId + } + const res = await getConfigList(params) this.list = res.data || [] this.total = res.total || 0 } finally { @@ -224,6 +239,12 @@ export default { this.profiles = res.data || [] } catch (e) {} }, + async loadApps() { + try { + const res = await request({ url: '/admin/app/options', method: 'get' }) + this.apps = res.data || [] + } catch (e) {} + }, handlePageChange(page) { this.query.pageNo = page this.loadData() diff --git a/web/src/views/group/GroupList.vue b/web/src/views/group/GroupList.vue index 3ba7857..23447ba 100644 --- a/web/src/views/group/GroupList.vue +++ b/web/src/views/group/GroupList.vue @@ -2,9 +2,14 @@
业务组管理 - 新建业务组 +
+ + + + 新建业务组 +
- + @@ -51,6 +56,7 @@ export default { return { list: [], loading: false, + filterName: '', dialogVisible: false, dialogTitle: '', form: {}, @@ -61,6 +67,15 @@ export default { users: [] } }, + computed: { + groupOptions() { + return [...new Set(this.list.map(g => g.name))].filter(name => name && name.trim()) + }, + filteredList() { + if (!this.filterName) return this.list + return this.list.filter(g => g.name === this.filterName) + } + }, created() { this.loadData() this.loadUsers() diff --git a/web/src/views/user/UserList.vue b/web/src/views/user/UserList.vue index 5ac8337..c2ccbba 100644 --- a/web/src/views/user/UserList.vue +++ b/web/src/views/user/UserList.vue @@ -2,7 +2,22 @@
用户管理 - 新建用户 +
+ + + + + + + + + + + + + + 新建用户 +
@@ -73,6 +88,13 @@ export default { return { list: [], loading: false, + query: { + name: '', + type: '', + groupId: '', + enabled: '' + }, + groups: [], dialogVisible: false, dialogTitle: '', form: {}, @@ -85,17 +107,29 @@ export default { }, created() { this.loadData() + this.loadGroups() }, methods: { async loadData() { this.loading = true try { - const res = await request({ url: '/admin/user/list', method: 'get' }) + const params = {} + if (this.query.name) params.name = this.query.name + if (this.query.type) params.type = this.query.type + if (this.query.groupId) params.groupId = this.query.groupId + if (this.query.enabled !== '') params.enabled = this.query.enabled + const res = await request({ url: '/admin/user/list', method: 'get', params }) this.list = res.data || [] } finally { this.loading = false } }, + async loadGroups() { + try { + const res = await request({ url: '/admin/group/options', method: 'get' }) + this.groups = res.data || [] + } catch (e) {} + }, showAdd() { this.dialogTitle = '新建用户' this.form = { name: '', mobile: '', email: '', type: 'user', groupName: '' }