fix: 后端直接托管 Web 控制台修复 #62,并修复 CodeQL 安全告警 (#70)

* fix(server): 后端直接托管 Web 控制台,修复无 nginx 时 404 (#62)

问题 #62:在未安装 nginx 的服务器上,访问 :8340/ 返回
"route not found"(404),Web 控制台完全无法打开;同时 systemd
服务以 backupx 用户启动时因无权读取 root:root 0640 的配置文件
而反复退出(exit 1)。

修复:
- 后端新增 SPA 静态托管:自动探测前端目录(./web、./web/dist、
  /opt/backupx/web 等,或 server.web_root 显式指定),命中后直接
  提供静态文件与 index.html 回退,无需额外 nginx 反向代理即可访问
  控制台。/api、/health、/metrics、/install 等保留前缀仍返回结构化
  JSON 404,不会被 SPA 回退污染(沿用 issue #46 的约定)。
- 含 ".." 的请求路径由文件服务层直接拒绝,叠加 filepath.Rel 容器
  校验,杜绝目录穿越。
- install.sh 以 backupx:backupx 安装配置文件并显式 chown,修复历史
  版本 root:root 0640 导致服务无法读取配置而启动失败的问题;安装
  完成提示同步说明可直接通过 :8340 访问,并给出 journalctl 排查命令。
- 新增 spa_test.go 覆盖目录探测、保留前缀判定、SPA 回退与穿越防护。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(security): 修复邮件头注入,加固 webhook 与整数转换

CodeQL 静态扫描在 main 上的真实告警修复:
- 邮件通知(email.go):From/To/Subject 头部此前直接拼接用户可控
  内容(备份任务名会进入 Subject),存在 SMTP 头注入风险(可注入
  Bcc 等额外头部或伪造正文)。新增 buildRawMessage/sanitizeHeaderValue
  剔除头部值中的 CR/LF;正文保持原样。新增 email_test.go 覆盖。
- webhook 通知(webhook.go):Validate 增加 URL 解析与 http/https
  协议校验,杜绝 file://、gopher:// 等可用于 SSRF 的协议。
- 整数转换(auth_service.go、storage_target_handler.go、
  backup_record_handler.go):将 ParseUint 的 bitSize 由 64 改为 0
  (即 uint 宽度),消除 uint64→uint 的潜在截断(32 位平台上为越界
  拒绝而非静默截断),并清除 go/incorrect-integer-conversion 告警。

注:archive.go/file_runner.go 的 zipslip 告警为误报(已有 HasPrefix
容器校验且不解压符号链接);node FS 浏览与 webhook 目标主机由设计上
的鉴权用户控制,不在本次行为变更范围内。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Wu Qing
2026-05-26 12:50:57 +08:00
committed by GitHub
co-authored by Claude Opus 4.7
parent 5a936ee162
commit 17f4ec63ae
12 changed files with 396 additions and 9 deletions
+86
View File
@@ -0,0 +1,86 @@
package http
import (
stdhttp "net/http"
"os"
"path/filepath"
"strings"
"github.com/gin-gonic/gin"
)
// resolveWebRoot 返回前端静态资源目录。优先使用显式配置的路径,
// 否则按部署惯例依次探测常见位置,返回首个包含 index.html 的目录。
// 返回空字符串表示未找到前端产物,此时后端退化为纯 API 服务。
func resolveWebRoot(configured string) string {
candidates := []string{
configured,
"./web/dist", // 源码树根目录构建产物(优先于 ./web,避免命中前端源码模板)
"./web", // systemdWorkingDirectory=/opt/backupx → /opt/backupx/web;容器 WORKDIR=/app → /app/web
"../web/dist", // 从 server/ 目录运行(make dev-server
"/opt/backupx/web",
"/app/web",
}
for _, dir := range candidates {
if strings.TrimSpace(dir) == "" {
continue
}
if hasIndexHTML(dir) {
if abs, err := filepath.Abs(dir); err == nil {
return abs
}
return dir
}
}
return ""
}
func hasIndexHTML(dir string) bool {
info, err := os.Stat(filepath.Join(dir, "index.html"))
return err == nil && !info.IsDir()
}
// isReservedBackendPath 判断请求是否命中后端保留前缀(API、探针、安装脚本)。
// 这些路径即使未匹配到具体路由,也应返回结构化 JSON 404,而不是回退到
// 前端 index.html —— 否则反向代理/安装脚本会把 HTML 当成接口响应(参考 issue #46)。
func isReservedBackendPath(p string) bool {
switch p {
case "/health", "/ready", "/metrics", "/api", "/install":
return true
}
return strings.HasPrefix(p, "/api/") || strings.HasPrefix(p, "/install/")
}
// spaFileServer 构造 SPA 静态资源处理器,用作 gin 的 NoRoute 回退:
// - 后端保留前缀返回 apiNotFoundJSON 404);
// - 其余 GET/HEAD 请求若在 webRoot 内命中真实文件则直接返回该文件;
// - 未命中文件的路径回退到 index.html,交由前端路由处理(history 模式刷新)。
func spaFileServer(webRoot string, apiNotFound gin.HandlerFunc) gin.HandlerFunc {
indexPath := filepath.Join(webRoot, "index.html")
return func(c *gin.Context) {
reqPath := c.Request.URL.Path
if isReservedBackendPath(reqPath) {
apiNotFound(c)
return
}
if c.Request.Method != stdhttp.MethodGet && c.Request.Method != stdhttp.MethodHead {
apiNotFound(c)
return
}
// 防目录穿越:以 webRoot 为根清理路径,确保最终目标仍位于 webRoot 内。
clean := filepath.Clean("/" + strings.TrimPrefix(reqPath, "/"))
target := filepath.Join(webRoot, clean)
if rel, err := filepath.Rel(webRoot, target); err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
apiNotFound(c)
return
}
if info, err := os.Stat(target); err == nil && !info.IsDir() {
c.File(target)
return
}
// 前端 SPA 路由(/dashboard、/tasks 等)回退到 index.html。
c.File(indexPath)
}
}