Files
BackupX/server/internal/http/spa_test.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

176 lines
5.5 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 (
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"github.com/gin-gonic/gin"
)
func TestResolveWebRoot(t *testing.T) {
gin.SetMode(gin.TestMode)
t.Run("explicit configured dir with index.html", func(t *testing.T) {
dir := t.TempDir()
writeIndex(t, dir)
got := resolveWebRoot(dir)
abs, _ := filepath.Abs(dir)
if got != abs {
t.Fatalf("resolveWebRoot(%q) = %q, want %q", dir, got, abs)
}
})
t.Run("configured dir without index.html falls through to none", func(t *testing.T) {
dir := t.TempDir() // no index.html, and no conventional ./web in CWD during test
if got := resolveWebRoot(dir); got != "" {
// 允许 CWD 恰好存在约定目录的环境,但临时目录本身不应被选中。
abs, _ := filepath.Abs(dir)
if got == abs {
t.Fatalf("expected dir without index.html to be skipped, got %q", got)
}
}
})
t.Run("empty configured uses auto-detect order", func(t *testing.T) {
// 切到一个仅含 ./web/dist/index.html 的临时工作目录,验证自动探测。
root := t.TempDir()
distDir := filepath.Join(root, "web", "dist")
if err := os.MkdirAll(distDir, 0o755); err != nil {
t.Fatal(err)
}
writeIndex(t, distDir)
restore := chdir(t, root)
defer restore()
// 以 chdir 之后的实际工作目录为基准计算期望值,避免 macOS 上
// /var → /private/var 符号链接导致字符串不一致。
wd, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
want := filepath.Join(wd, "web", "dist")
got := resolveWebRoot("")
if got != want {
t.Fatalf("auto-detect = %q, want %q", got, want)
}
})
}
func TestIsReservedBackendPath(t *testing.T) {
reserved := []string{"/health", "/ready", "/metrics", "/api", "/install", "/api/", "/api/system/info", "/install/abc", "/install/abc/compose.yml"}
for _, p := range reserved {
if !isReservedBackendPath(p) {
t.Errorf("isReservedBackendPath(%q) = false, want true", p)
}
}
notReserved := []string{"/", "/dashboard", "/assets/app.js", "/installer", "/apidocs", "/favicon.ico"}
for _, p := range notReserved {
if isReservedBackendPath(p) {
t.Errorf("isReservedBackendPath(%q) = true, want false", p)
}
}
}
func TestSpaFileServer(t *testing.T) {
gin.SetMode(gin.TestMode)
webRoot := t.TempDir()
writeIndexContent(t, webRoot, "<!doctype html><title>BackupX</title>")
assetsDir := filepath.Join(webRoot, "assets")
if err := os.MkdirAll(assetsDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(assetsDir, "app.js"), []byte("console.log(1)"), 0o644); err != nil {
t.Fatal(err)
}
apiNotFoundHit := false
apiNotFound := func(c *gin.Context) {
apiNotFoundHit = true
c.JSON(http.StatusNotFound, gin.H{"code": "NOT_FOUND"})
}
engine := gin.New()
engine.NoRoute(spaFileServer(webRoot, apiNotFound))
cases := []struct {
name string
method string
path string
wantStatus int
wantBody string // 子串;为空表示不校验
wantAPI404 bool
}{
{name: "root serves index", method: http.MethodGet, path: "/", wantStatus: 200, wantBody: "BackupX"},
{name: "spa route falls back to index", method: http.MethodGet, path: "/dashboard", wantStatus: 200, wantBody: "BackupX"},
{name: "real asset served", method: http.MethodGet, path: "/assets/app.js", wantStatus: 200, wantBody: "console.log"},
{name: "api path returns json 404", method: http.MethodGet, path: "/api/garbage", wantStatus: 404, wantAPI404: true},
{name: "health returns json 404 via reserved", method: http.MethodGet, path: "/health", wantStatus: 404, wantAPI404: true},
{name: "non-GET on spa path is api 404", method: http.MethodPost, path: "/dashboard", wantStatus: 404, wantAPI404: true},
{name: "directory falls back to index", method: http.MethodGet, path: "/assets/", wantStatus: 200, wantBody: "BackupX"},
// 含 ".." 的请求路径被 net/http 在文件服务层直接拒绝400 invalid URL path
// 绝不会泄露 webRoot 之外的文件;这是在 filepath.Rel 校验之上的纵深防御。
{name: "traversal rejected, never serves passwd", method: http.MethodGet, path: "/../../etc/passwd", wantStatus: 400, wantBody: "invalid URL path"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
apiNotFoundHit = false
req := httptest.NewRequest(tc.method, tc.path, nil)
rec := httptest.NewRecorder()
engine.ServeHTTP(rec, req)
if rec.Code != tc.wantStatus {
t.Fatalf("status = %d, want %d (body=%q)", rec.Code, tc.wantStatus, rec.Body.String())
}
if tc.wantBody != "" && !contains(rec.Body.String(), tc.wantBody) {
t.Fatalf("body %q does not contain %q", rec.Body.String(), tc.wantBody)
}
if tc.wantAPI404 != apiNotFoundHit {
t.Fatalf("apiNotFoundHit = %v, want %v", apiNotFoundHit, tc.wantAPI404)
}
})
}
}
func writeIndex(t *testing.T, dir string) {
t.Helper()
writeIndexContent(t, dir, "<!doctype html><title>BackupX</title>")
}
func writeIndexContent(t *testing.T, dir, content string) {
t.Helper()
if err := os.WriteFile(filepath.Join(dir, "index.html"), []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}
func chdir(t *testing.T, dir string) func() {
t.Helper()
orig, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
if err := os.Chdir(dir); err != nil {
t.Fatal(err)
}
return func() { _ = os.Chdir(orig) }
}
func contains(s, sub string) bool {
return len(sub) == 0 || (len(s) >= len(sub) && indexOf(s, sub) >= 0)
}
func indexOf(s, sub string) int {
for i := 0; i+len(sub) <= len(s); i++ {
if s[i:i+len(sub)] == sub {
return i
}
}
return -1
}