Files
BackupX/server/internal/http/spa.go
Wu Qing 17f4ec63ae 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>
2026-05-26 12:50:57 +08:00

87 lines
2.9 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}