修复: 后端审查发现的 5 项问题

根据 Spec + Code Quality 双审查修复:

1. BatchCreate 事务保护(node_service.go/node_repository.go)
   原循环 Create 在 DB 约束失败时会残留半截数据。改为预先构造所有 Node
   再走 repo.BatchCreate 单一事务,任一失败整体回滚。

2. Peek 语义与 Consume 对齐(agent_install_token_repository.go)
   FindByToken 无条件返回任意记录,导致已消费/已过期的僵尸 token
   可通过 compose 端点的 mode 检查但必然 Consume 失败,出现 410 假错。
   新增 FindValidByToken,Peek 改用之。

3. MasterURL / AgentToken / AgentVersion 渲染前校验(installscript/renderer.go)
   防止 YAML 注入(换行/引号逃逸 compose 配置)、shell 注入($(...))、
   非法字符。加 TestRenderScriptRejects* 系列测试覆盖。

4. ipLimiter 无界增长修复(install_handler.go)
   新增 gc 方法 + startGC 后台协程,每 window 周期清理过期 IP 条目。
   RouterDependencies.Context 控制生命周期;app 传入 ctx,测试 t.Cleanup 取消。

5. CreateInstallToken 的 CreatedByID 从 JWT subject 解析(node_handler.go)
   原硬编码 0 导致审计不可追溯。新增 resolveCurrentUserID helper,
   借助 UserRepository 把 JWT subject(用户名)→ user.ID;失败退回 0。
This commit is contained in:
Awuqing
2026-04-19 17:14:17 +08:00
parent e28f2d3454
commit 5c896bb96c
11 changed files with 289 additions and 18 deletions

View File

@@ -61,7 +61,13 @@ func setupInstallFlowRouter(t *testing.T) (http.Handler, string) {
auditLogRepo := repository.NewAuditLogRepository(db)
auditSvc := service.NewAuditService(auditLogRepo)
// 用 cancelable ctx测试结束时停掉 handler 启动的后台 GC 协程,
// 避免 goroutine 持有 map 导致 tempdir 清理失败。
ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(cancel)
router := NewRouter(RouterDependencies{
Context: ctx,
Config: cfg,
Version: "test",
Logger: log,

View File

@@ -1,6 +1,7 @@
package http
import (
"context"
stdhttp "net/http"
"strconv"
"strings"
@@ -21,12 +22,16 @@ type InstallHandler struct {
limiter *ipLimiter
}
func NewInstallHandler(tokenService *service.InstallTokenService, auditService *service.AuditService, externalURL string) *InstallHandler {
// NewInstallHandler 构造 handler 并启动限流器的后台 GC 协程。
// gcCtx 控制 GC 协程生命周期,建议传入 app context。
func NewInstallHandler(gcCtx context.Context, tokenService *service.InstallTokenService, auditService *service.AuditService, externalURL string) *InstallHandler {
limiter := newIPLimiter(20, time.Minute)
limiter.startGC(gcCtx)
return &InstallHandler{
tokenService: tokenService,
auditService: auditService,
externalURL: externalURL,
limiter: newIPLimiter(20, time.Minute),
limiter: limiter,
}
}
@@ -177,3 +182,40 @@ func (l *ipLimiter) allow(ip string) bool {
l.events[ip] = append(keep, now)
return true
}
// gc 清理窗口外所有过期的 IP 条目,防止公网扫描导致 map 无界增长。
// 由后台 goroutine 周期性调用。
func (l *ipLimiter) gc(now time.Time) {
l.mu.Lock()
defer l.mu.Unlock()
cutoff := now.Add(-l.window)
for k, v := range l.events {
stale := true
for _, t := range v {
if t.After(cutoff) {
stale = false
break
}
}
if stale {
delete(l.events, k)
}
}
}
// startGC 启动后台清理协程,每 window 周期清扫一次 map。
// ctx 取消时协程退出。
func (l *ipLimiter) startGC(ctx context.Context) {
go func() {
ticker := time.NewTicker(l.window)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case t := <-ticker.C:
l.gc(t)
}
}
}()
}

View File

@@ -7,6 +7,7 @@ import (
"backupx/server/internal/apperror"
"backupx/server/internal/installscript"
"backupx/server/internal/repository"
"backupx/server/internal/service"
"backupx/server/pkg/response"
"github.com/gin-gonic/gin"
@@ -16,23 +17,49 @@ type NodeHandler struct {
service *service.NodeService
auditService *service.AuditService
installTokenSvc *service.InstallTokenService
userRepo repository.UserRepository
externalURL string
}
// NewNodeHandler 构造 handler。
// userRepo 用于把 JWT subject用户名解析为 user.ID填入 install_token.created_by_id 做审计追溯;
// 传 nil 时 created_by_id 记为 0仍可用不阻断
func NewNodeHandler(
nodeService *service.NodeService,
auditService *service.AuditService,
installTokenSvc *service.InstallTokenService,
userRepo repository.UserRepository,
externalURL string,
) *NodeHandler {
return &NodeHandler{
service: nodeService,
auditService: auditService,
installTokenSvc: installTokenSvc,
userRepo: userRepo,
externalURL: externalURL,
}
}
// resolveCurrentUserID 从 JWT subject 解析出 user.ID失败返回 0。
func (h *NodeHandler) resolveCurrentUserID(c *gin.Context) uint {
if h.userRepo == nil {
return 0
}
subjectValue, ok := c.Get(contextUserSubjectKey)
if !ok {
return 0
}
subject, err := service.SubjectFromContextValue(subjectValue)
if err != nil || subject == "" {
return 0
}
user, err := h.userRepo.FindByUsername(c.Request.Context(), subject)
if err != nil || user == nil {
return 0
}
return user.ID
}
func (h *NodeHandler) List(c *gin.Context) {
items, err := h.service.List(c.Request.Context())
if err != nil {
@@ -224,7 +251,7 @@ func (h *NodeHandler) CreateInstallToken(c *gin.Context) {
AgentVersion: input.AgentVersion,
DownloadSrc: input.DownloadSrc,
TTLSeconds: input.TTLSeconds,
CreatedByID: 0, // 如需关联 userID后续可通过 auth 中间件注入
CreatedByID: h.resolveCurrentUserID(c),
})
if err != nil {
response.Error(c, err)

View File

@@ -1,6 +1,7 @@
package http
import (
"context"
"errors"
stdhttp "net/http"
@@ -15,6 +16,9 @@ import (
)
type RouterDependencies struct {
// Context 控制 handler 启动的后台协程(如 ipLimiter GC的生命周期。
// app 应传入随进程退出可取消的 ctx若为 nil 则退化为 context.Background()。
Context context.Context
Config config.Config
Version string
Logger *zap.Logger
@@ -143,7 +147,7 @@ func NewRouter(deps RouterDependencies) *gin.Engine {
database.POST("/discover", databaseHandler.Discover)
}
nodeHandler := NewNodeHandler(deps.NodeService, deps.AuditService, deps.InstallTokenService, deps.MasterExternalURL)
nodeHandler := NewNodeHandler(deps.NodeService, deps.AuditService, deps.InstallTokenService, deps.UserRepository, deps.MasterExternalURL)
nodes := api.Group("/nodes")
nodes.Use(AuthMiddleware(deps.JWTManager))
nodes.GET("", nodeHandler.List)
@@ -178,7 +182,11 @@ func NewRouter(deps RouterDependencies) *gin.Engine {
// 公开安装路由(不走 JWT 中间件)
if deps.InstallTokenService != nil {
installHandler := NewInstallHandler(deps.InstallTokenService, deps.AuditService, deps.MasterExternalURL)
gcCtx := deps.Context
if gcCtx == nil {
gcCtx = context.Background()
}
installHandler := NewInstallHandler(gcCtx, deps.InstallTokenService, deps.AuditService, deps.MasterExternalURL)
engine.GET("/install/:token", installHandler.Script)
engine.GET("/install/:token/compose.yml", installHandler.Compose)
}