修复: 后端审查发现的 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

@@ -7,6 +7,8 @@ import (
"bytes"
_ "embed"
"fmt"
"net/url"
"strings"
"text/template"
"backupx/server/internal/model"
@@ -43,6 +45,9 @@ func DownloadBaseFor(src string) string {
// RenderScript 渲染目标机安装脚本。
func RenderScript(ctx Context) (string, error) {
ctx = withDefaults(ctx)
if err := validateContext(ctx); err != nil {
return "", err
}
tmpl, err := template.New("install").Parse(installScriptTmpl)
if err != nil {
return "", fmt.Errorf("parse template: %w", err)
@@ -57,6 +62,9 @@ func RenderScript(ctx Context) (string, error) {
// RenderComposeYaml 渲染 docker-compose.yml 片段。
func RenderComposeYaml(ctx Context) (string, error) {
ctx = withDefaults(ctx)
if err := validateContext(ctx); err != nil {
return "", err
}
tmpl, err := template.New("compose").Parse(composeYamlTmpl)
if err != nil {
return "", fmt.Errorf("parse template: %w", err)
@@ -68,6 +76,89 @@ func RenderComposeYaml(ctx Context) (string, error) {
return buf.String(), nil
}
// validateContext 对模板变量做安全校验,防止 YAML/shell 注入。
// - MasterURL必须是合法 http(s) URL无控制字符
// - AgentToken仅允许 hex 字符,最长 128
// - AgentVersion仅允许 tag 常见字符(字母数字、点、连字符、下划线、加号)
//
// 这些字段被直接写入 shell 双引号字符串和 YAML 双引号值;不做校验会带来
// 注入风险(如 MasterURL 含 `"\nCOMMAND:` 可逃逸 YAML 结构)。
func validateContext(ctx Context) error {
if err := validateMasterURL(ctx.MasterURL); err != nil {
return err
}
if err := validateAgentToken(ctx.AgentToken); err != nil {
return err
}
if err := validateAgentVersion(ctx.AgentVersion); err != nil {
return err
}
return nil
}
func validateMasterURL(raw string) error {
raw = strings.TrimSpace(raw)
if raw == "" {
return fmt.Errorf("master URL empty")
}
if strings.ContainsAny(raw, " \t\r\n\"'`$\\") {
return fmt.Errorf("master URL contains illegal characters")
}
u, err := url.Parse(raw)
if err != nil {
return fmt.Errorf("invalid master URL: %w", err)
}
if u.Scheme != "http" && u.Scheme != "https" {
return fmt.Errorf("master URL scheme must be http or https, got %q", u.Scheme)
}
if u.Host == "" {
return fmt.Errorf("master URL missing host")
}
return nil
}
// validateAgentToken 允许占位符 <AGENT_TOKEN>PreviewScript 使用),
// 或 32 字节 hex64 字符)+ 小幅兼容16-128 hex 字符)
func validateAgentToken(tok string) error {
if tok == "<AGENT_TOKEN>" {
return nil
}
if len(tok) < 8 || len(tok) > 128 {
return fmt.Errorf("agent token length out of range")
}
for _, c := range tok {
switch {
case c >= '0' && c <= '9':
case c >= 'a' && c <= 'f':
case c >= 'A' && c <= 'F':
default:
return fmt.Errorf("agent token must be hex")
}
}
return nil
}
func validateAgentVersion(v string) error {
v = strings.TrimSpace(v)
if v == "" {
return fmt.Errorf("agent version empty")
}
if len(v) > 64 {
return fmt.Errorf("agent version too long")
}
for _, c := range v {
switch {
case c >= '0' && c <= '9':
case c >= 'a' && c <= 'z':
case c >= 'A' && c <= 'Z':
case c == '.' || c == '-' || c == '_' || c == '+':
default:
return fmt.Errorf("agent version contains illegal char %q", c)
}
}
return nil
}
func withDefaults(ctx Context) Context {
if ctx.InstallPrefix == "" {
ctx.InstallPrefix = "/opt/backupx-agent"

View File

@@ -7,9 +7,10 @@ import (
"backupx/server/internal/model"
)
// 使用合法 hex token32 字节 = 64 字符)以通过 validateAgentToken 校验
var testCtx = Context{
MasterURL: "https://master.example.com",
AgentToken: "test-token-hex",
AgentToken: "deadbeefcafebabe0123456789abcdef0123456789abcdef0123456789abcdef",
AgentVersion: "v1.7.0",
Mode: model.InstallModeSystemd,
Arch: model.InstallArchAuto,
@@ -30,7 +31,7 @@ func TestRenderScriptSystemd(t *testing.T) {
"systemctl enable --now backupx-agent",
"X-Agent-Token: ${AGENT_TOKEN}",
"MASTER_URL=\"https://master.example.com\"",
"AGENT_TOKEN=\"test-token-hex\"",
"AGENT_TOKEN=\"deadbeefcafebabe0123456789abcdef0123456789abcdef0123456789abcdef\"",
}
for _, s := range mustContain {
if !strings.Contains(got, s) {
@@ -91,11 +92,60 @@ func TestRenderComposeYaml(t *testing.T) {
if !strings.Contains(got, "image: awuqing/backupx:v1.7.0") {
t.Errorf("compose missing image:\n%s", got)
}
if !strings.Contains(got, `BACKUPX_AGENT_TOKEN: "test-token-hex"`) {
if !strings.Contains(got, `BACKUPX_AGENT_TOKEN: "deadbeefcafebabe0123456789abcdef0123456789abcdef0123456789abcdef"`) {
t.Errorf("compose missing token env:\n%s", got)
}
}
func TestRenderScriptRejectsInjectedMasterURL(t *testing.T) {
bad := []string{
"https://example.com\" other: inject", // 含引号和空格
"javascript:alert(1)", // scheme 非法
"https://example.com\n- privileged", // 含换行YAML 注入经典 payload
"", // 空
}
for _, u := range bad {
ctx := testCtx
ctx.MasterURL = u
if _, err := RenderScript(ctx); err == nil {
t.Errorf("RenderScript should reject MasterURL %q", u)
}
}
}
func TestRenderComposeYamlRejectsInjectedMasterURL(t *testing.T) {
ctx := testCtx
ctx.Mode = model.InstallModeDocker
ctx.MasterURL = "https://example.com\n- privileged: true"
if _, err := RenderComposeYaml(ctx); err == nil {
t.Errorf("RenderComposeYaml should reject injected MasterURL")
}
}
func TestRenderScriptRejectsBadToken(t *testing.T) {
ctx := testCtx
ctx.AgentToken = "not-hex-token" // 非 hex
if _, err := RenderScript(ctx); err == nil {
t.Errorf("should reject non-hex agent token")
}
}
func TestRenderScriptAcceptsPlaceholderToken(t *testing.T) {
ctx := testCtx
ctx.AgentToken = "<AGENT_TOKEN>" // Preview 占位符
if _, err := RenderScript(ctx); err != nil {
t.Errorf("should accept placeholder token: %v", err)
}
}
func TestRenderScriptRejectsBadVersion(t *testing.T) {
ctx := testCtx
ctx.AgentVersion = "v1.7 && rm -rf /" // 含非法字符
if _, err := RenderScript(ctx); err == nil {
t.Errorf("should reject version with shell metacharacters")
}
}
func TestDownloadBaseMapping(t *testing.T) {
cases := map[string]string{
model.InstallSourceGitHub: "https://github.com/Awuqing/BackupX/releases/download",