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

265 lines
7.8 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 (
"fmt"
"strconv"
"strings"
"backupx/server/internal/apperror"
"backupx/server/internal/service"
"backupx/server/pkg/response"
"github.com/gin-gonic/gin"
)
type StorageTargetHandler struct {
service *service.StorageTargetService
auditService *service.AuditService
}
type storageTargetGoogleDriveAuthRequest struct {
TargetID *uint `json:"targetId"`
Name string `json:"name"`
Type string `json:"type"`
Description string `json:"description"`
Enabled bool `json:"enabled"`
Config map[string]any `json:"config"`
ClientID string `json:"clientId"`
ClientSecret string `json:"clientSecret"`
FolderID string `json:"folderId"`
}
func NewStorageTargetHandler(service *service.StorageTargetService, auditService *service.AuditService) *StorageTargetHandler {
return &StorageTargetHandler{service: service, auditService: auditService}
}
func (h *StorageTargetHandler) List(c *gin.Context) {
items, err := h.service.List(c.Request.Context())
if err != nil {
response.Error(c, err)
return
}
response.Success(c, items)
}
func (h *StorageTargetHandler) Get(c *gin.Context) {
id, ok := parseUintParam(c, "id")
if !ok {
return
}
item, err := h.service.Get(c.Request.Context(), id)
if err != nil {
response.Error(c, err)
return
}
response.Success(c, item)
}
func (h *StorageTargetHandler) Create(c *gin.Context) {
var input service.StorageTargetUpsertInput
if err := c.ShouldBindJSON(&input); err != nil {
response.Error(c, apperror.BadRequest("STORAGE_TARGET_INVALID", "存储目标参数不合法", err))
return
}
item, err := h.service.Create(c.Request.Context(), input)
if err != nil {
response.Error(c, err)
return
}
recordAudit(c, h.auditService, "storage_target", "create", "storage_target", fmt.Sprintf("%d", item.ID), item.Name,
fmt.Sprintf("创建存储目标「%s」类型: %s", item.Name, input.Type))
response.Success(c, item)
}
func (h *StorageTargetHandler) Update(c *gin.Context) {
id, ok := parseUintParam(c, "id")
if !ok {
return
}
var input service.StorageTargetUpsertInput
if err := c.ShouldBindJSON(&input); err != nil {
response.Error(c, apperror.BadRequest("STORAGE_TARGET_INVALID", "存储目标参数不合法", err))
return
}
item, err := h.service.Update(c.Request.Context(), id, input)
if err != nil {
response.Error(c, err)
return
}
recordAudit(c, h.auditService, "storage_target", "update", "storage_target", fmt.Sprintf("%d", item.ID), item.Name,
fmt.Sprintf("更新存储目标「%s」类型: %s", item.Name, input.Type))
response.Success(c, item)
}
func (h *StorageTargetHandler) Delete(c *gin.Context) {
id, ok := parseUintParam(c, "id")
if !ok {
return
}
if err := h.service.Delete(c.Request.Context(), id); err != nil {
response.Error(c, err)
return
}
recordAudit(c, h.auditService, "storage_target", "delete", "storage_target", fmt.Sprintf("%d", id), "",
fmt.Sprintf("删除存储目标 (ID: %d)", id))
response.Success(c, gin.H{"deleted": true})
}
func (h *StorageTargetHandler) TestConnection(c *gin.Context) {
var payload service.StorageTargetUpsertInput
if err := c.ShouldBindJSON(&payload); err != nil {
response.Error(c, apperror.BadRequest("STORAGE_TARGET_TEST_INVALID", "测试连接参数不合法", err))
return
}
if err := h.service.TestConnection(c.Request.Context(), service.StorageTargetTestInput{Payload: payload}); err != nil {
response.Error(c, err)
return
}
response.Success(c, gin.H{"success": true, "message": "连接成功"})
}
func (h *StorageTargetHandler) TestSavedConnection(c *gin.Context) {
id, ok := parseUintParam(c, "id")
if !ok {
return
}
if err := h.service.TestConnection(c.Request.Context(), service.StorageTargetTestInput{TargetID: &id}); err != nil {
response.Error(c, err)
return
}
response.Success(c, gin.H{"success": true, "message": "连接成功"})
}
func (h *StorageTargetHandler) StartGoogleDriveOAuth(c *gin.Context) {
var request storageTargetGoogleDriveAuthRequest
if err := c.ShouldBindJSON(&request); err != nil {
response.Error(c, apperror.BadRequest("STORAGE_GOOGLE_OAUTH_INVALID", "Google Drive 授权参数不合法", err))
return
}
input := service.GoogleDriveAuthStartInput{
TargetID: request.TargetID,
Name: strings.TrimSpace(request.Name),
Description: strings.TrimSpace(request.Description),
Enabled: request.Enabled,
ClientID: firstNonEmpty(asString(request.Config["clientId"]), request.ClientID),
ClientSecret: firstNonEmpty(asString(request.Config["clientSecret"]), request.ClientSecret),
FolderID: firstNonEmpty(asString(request.Config["folderId"]), request.FolderID),
}
result, err := h.service.StartGoogleDriveOAuth(c.Request.Context(), input, requestOrigin(c))
if err != nil {
response.Error(c, err)
return
}
response.Success(c, gin.H{"authUrl": result.AuthorizationURL})
}
func (h *StorageTargetHandler) CompleteGoogleDriveOAuth(c *gin.Context) {
var input service.GoogleDriveAuthCompleteInput
if err := c.ShouldBindJSON(&input); err != nil {
response.Error(c, apperror.BadRequest("STORAGE_GOOGLE_OAUTH_INVALID", "Google Drive 回调参数不合法", err))
return
}
item, err := h.service.CompleteGoogleDriveOAuth(c.Request.Context(), input)
if err != nil {
response.Error(c, err)
return
}
response.Success(c, item)
}
func (h *StorageTargetHandler) HandleGoogleDriveCallback(c *gin.Context) {
if queryError := strings.TrimSpace(c.Query("error")); queryError != "" {
response.Success(c, gin.H{"success": false, "message": queryError})
return
}
input := service.GoogleDriveAuthCompleteInput{State: strings.TrimSpace(c.Query("state")), Code: strings.TrimSpace(c.Query("code"))}
if input.State == "" || input.Code == "" {
response.Error(c, apperror.BadRequest("STORAGE_GOOGLE_OAUTH_INVALID", "Google Drive 回调参数不合法", nil))
return
}
item, err := h.service.CompleteGoogleDriveOAuth(c.Request.Context(), input)
if err != nil {
response.Error(c, err)
return
}
response.Success(c, gin.H{"success": true, "message": "Google Drive 授权成功", "target": item})
}
func (h *StorageTargetHandler) GoogleDriveProfile(c *gin.Context) {
id, ok := parseUintParam(c, "id")
if !ok {
return
}
profile, err := h.service.GoogleDriveProfile(c.Request.Context(), id)
if err != nil {
response.Error(c, err)
return
}
response.Success(c, profile)
}
func parseUintParam(c *gin.Context, key string) (uint, bool) {
value := strings.TrimSpace(c.Param(key))
parsed, err := strconv.ParseUint(value, 10, 0)
if err != nil {
response.Error(c, apperror.BadRequest("INVALID_ID", fmt.Sprintf("参数 %s 不合法", key), err))
return 0, false
}
return uint(parsed), true
}
func requestOrigin(c *gin.Context) string {
origin := strings.TrimSpace(c.GetHeader("Origin"))
if origin != "" {
return origin
}
scheme := strings.TrimSpace(c.GetHeader("X-Forwarded-Proto"))
if scheme == "" {
if c.Request.TLS != nil {
scheme = "https"
} else {
scheme = "http"
}
}
return fmt.Sprintf("%s://%s", scheme, c.Request.Host)
}
func asString(value any) string {
text, _ := value.(string)
return strings.TrimSpace(text)
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return strings.TrimSpace(value)
}
}
return ""
}
func (h *StorageTargetHandler) ToggleStar(c *gin.Context) {
id, ok := parseUintParam(c, "id")
if !ok {
return
}
item, err := h.service.ToggleStar(c.Request.Context(), id)
if err != nil {
response.Error(c, err)
return
}
response.Success(c, item)
}
func (h *StorageTargetHandler) GetUsage(c *gin.Context) {
id, ok := parseUintParam(c, "id")
if !ok {
return
}
usage, err := h.service.GetUsage(c.Request.Context(), id)
if err != nil {
response.Error(c, err)
return
}
response.Success(c, usage)
}