mirror of
https://github.com/Awuqing/BackupX.git
synced 2026-05-28 03:29:35 +08:00
* 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>
176 lines
5.5 KiB
Go
176 lines
5.5 KiB
Go
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
|
||
}
|