mirror of
https://github.com/Awuqing/BackupX.git
synced 2026-05-27 19:19:35 +08:00
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:
@@ -59,8 +59,11 @@ cp -R "$WEB_SOURCE/." "$PREFIX/web/"
|
||||
chown -R "$APP_USER:$APP_GROUP" "$PREFIX"
|
||||
|
||||
if [ ! -f "$ETC_DIR/config.yaml" ]; then
|
||||
install -m 0640 "$CONFIG_TEMPLATE" "$ETC_DIR/config.yaml"
|
||||
install -o "$APP_USER" -g "$APP_GROUP" -m 0640 "$CONFIG_TEMPLATE" "$ETC_DIR/config.yaml"
|
||||
fi
|
||||
# 确保服务账户能读取配置:历史版本曾以 root:root 0640 安装配置,
|
||||
# 导致以 backupx 身份运行的服务因无权读取配置而启动失败(exit 1)。
|
||||
chown "$APP_USER:$APP_GROUP" "$ETC_DIR/config.yaml"
|
||||
|
||||
if [ -f "$SERVICE_SOURCE" ]; then
|
||||
install -m 0644 "$SERVICE_SOURCE" "/etc/systemd/system/$SERVICE_NAME.service"
|
||||
@@ -105,6 +108,14 @@ cat <<MESSAGE
|
||||
- 配置文件:$ETC_DIR/config.yaml
|
||||
- systemd 服务:/etc/systemd/system/$SERVICE_NAME.service
|
||||
|
||||
Web 控制台已由后端直接托管,无需额外的 nginx 反向代理即可访问:
|
||||
http://<本机IP>:8340
|
||||
|
||||
(如已安装 nginx,脚本会自动写入反向代理配置,可继续用 80 端口访问。)
|
||||
|
||||
排查:若服务未监听端口,请查看日志:
|
||||
journalctl -u "$SERVICE_NAME" -n 50 --no-pager
|
||||
|
||||
如需修改监听地址、数据库路径或日志级别,请编辑 "$ETC_DIR/config.yaml" 后执行:
|
||||
systemctl restart "$SERVICE_NAME"
|
||||
MESSAGE
|
||||
|
||||
@@ -4,6 +4,8 @@ server:
|
||||
port: 8340
|
||||
mode: "release" # debug | release
|
||||
external_url: "" # 可选:Master 对 Agent 可达的 URL,例如 https://backup.example.com
|
||||
web_root: "" # 前端静态目录;留空自动探测(./web、/opt/backupx/web 等)。
|
||||
# 命中后后端直接托管 Web 控制台,无需额外 nginx 反向代理。
|
||||
|
||||
database:
|
||||
path: "./data/backupx.db" # SQLite 数据库路径
|
||||
|
||||
@@ -21,6 +21,10 @@ type ServerConfig struct {
|
||||
Port int `mapstructure:"port"`
|
||||
Mode string `mapstructure:"mode"`
|
||||
ExternalURL string `mapstructure:"external_url"`
|
||||
// WebRoot 指向前端构建产物目录。留空时后端会按部署惯例自动探测
|
||||
// (./web、./web/dist、/opt/backupx/web 等)。探测命中后后端直接托管
|
||||
// 前端 SPA,无需额外的 nginx 反向代理即可访问 Web 控制台。
|
||||
WebRoot string `mapstructure:"web_root"`
|
||||
}
|
||||
|
||||
type DatabaseConfig struct {
|
||||
@@ -138,6 +142,7 @@ func applyDefaults(v *viper.Viper) {
|
||||
v.SetDefault("server.port", 8340)
|
||||
v.SetDefault("server.mode", "release")
|
||||
v.SetDefault("server.external_url", "")
|
||||
v.SetDefault("server.web_root", "")
|
||||
v.SetDefault("database.path", "./data/backupx.db")
|
||||
v.SetDefault("security.jwt_expire", "24h")
|
||||
v.SetDefault("backup.temp_dir", "/tmp/backupx")
|
||||
|
||||
@@ -216,7 +216,7 @@ func writeSSEEvent(writer io.Writer, event backup.LogEvent) error {
|
||||
}
|
||||
|
||||
func parseUintString(value string) (uint, bool) {
|
||||
parsed, err := strconv.ParseUint(strings.TrimSpace(value), 10, 64)
|
||||
parsed, err := strconv.ParseUint(strings.TrimSpace(value), 10, 0)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
@@ -353,9 +353,25 @@ func NewRouter(deps RouterDependencies) *gin.Engine {
|
||||
engine.GET("/api/install/:token/compose.yml", installHandler.Compose)
|
||||
}
|
||||
|
||||
engine.NoRoute(func(c *gin.Context) {
|
||||
// 未匹配路由处理:
|
||||
// - 找到前端产物目录时,托管 SPA(静态文件 + index.html 回退),
|
||||
// 使后端在无 nginx 反向代理时也能直接提供 Web 控制台(issue #62);
|
||||
// - 未找到前端目录时退化为纯 API 服务,统一返回结构化 JSON 404。
|
||||
apiNotFound := func(c *gin.Context) {
|
||||
response.Error(c, apperror.New(stdhttp.StatusNotFound, "NOT_FOUND", "接口不存在", errors.New("route not found")))
|
||||
})
|
||||
}
|
||||
if webRoot := resolveWebRoot(deps.Config.Server.WebRoot); webRoot != "" {
|
||||
if deps.Logger != nil {
|
||||
deps.Logger.Info("serving web frontend", zap.String("web_root", webRoot))
|
||||
}
|
||||
engine.NoRoute(spaFileServer(webRoot, apiNotFound))
|
||||
} else {
|
||||
if deps.Logger != nil {
|
||||
deps.Logger.Warn("web frontend directory not found; serving API only",
|
||||
zap.String("hint", "set server.web_root in config, or place the built frontend at ./web"))
|
||||
}
|
||||
engine.NoRoute(apiNotFound)
|
||||
}
|
||||
|
||||
return engine
|
||||
}
|
||||
|
||||
86
server/internal/http/spa.go
Normal file
86
server/internal/http/spa.go
Normal 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", // systemd:WorkingDirectory=/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 回退:
|
||||
// - 后端保留前缀返回 apiNotFound(JSON 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)
|
||||
}
|
||||
}
|
||||
175
server/internal/http/spa_test.go
Normal file
175
server/internal/http/spa_test.go
Normal file
@@ -0,0 +1,175 @@
|
||||
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
|
||||
}
|
||||
@@ -199,7 +199,7 @@ func (h *StorageTargetHandler) GoogleDriveProfile(c *gin.Context) {
|
||||
|
||||
func parseUintParam(c *gin.Context, key string) (uint, bool) {
|
||||
value := strings.TrimSpace(c.Param(key))
|
||||
parsed, err := strconv.ParseUint(value, 10, 64)
|
||||
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
|
||||
|
||||
@@ -37,13 +37,12 @@ func (n *EmailNotifier) Send(_ context.Context, config map[string]any, message M
|
||||
from := strings.TrimSpace(asString(config["from"]))
|
||||
toList := splitCommaValues(asString(config["to"]))
|
||||
address := host + ":" + strconv.Itoa(port)
|
||||
headers := []string{"From: " + from, "To: " + strings.Join(toList, ", "), "Subject: " + message.Title, "MIME-Version: 1.0", "Content-Type: text/plain; charset=UTF-8", "", message.Body}
|
||||
var auth smtp.Auth
|
||||
if username != "" {
|
||||
auth = smtp.PlainAuth("", username, password, host)
|
||||
}
|
||||
|
||||
rawMessage := []byte(strings.Join(headers, "\r\n"))
|
||||
rawMessage := buildRawMessage(from, toList, message)
|
||||
|
||||
if port == 465 {
|
||||
tlsConfig := &tls.Config{ServerName: host}
|
||||
@@ -86,3 +85,31 @@ func (n *EmailNotifier) Send(_ context.Context, config map[string]any, message M
|
||||
|
||||
return smtp.SendMail(address, auth, from, toList, rawMessage)
|
||||
}
|
||||
|
||||
// buildRawMessage 构造 RFC 5322 邮件原文。所有头部值都会剔除 CR/LF,
|
||||
// 防止 SMTP 头注入:备份任务名等用户可控内容会进入 Subject,若包含
|
||||
// 换行符可被用来注入额外头部(如 Bcc)或伪造正文。正文本身不做处理,
|
||||
// 允许包含换行。
|
||||
func buildRawMessage(from string, toList []string, message Message) []byte {
|
||||
sanitizedTo := make([]string, 0, len(toList))
|
||||
for _, addr := range toList {
|
||||
if s := sanitizeHeaderValue(addr); s != "" {
|
||||
sanitizedTo = append(sanitizedTo, s)
|
||||
}
|
||||
}
|
||||
headers := []string{
|
||||
"From: " + sanitizeHeaderValue(from),
|
||||
"To: " + strings.Join(sanitizedTo, ", "),
|
||||
"Subject: " + sanitizeHeaderValue(message.Title),
|
||||
"MIME-Version: 1.0",
|
||||
"Content-Type: text/plain; charset=UTF-8",
|
||||
"",
|
||||
message.Body,
|
||||
}
|
||||
return []byte(strings.Join(headers, "\r\n"))
|
||||
}
|
||||
|
||||
// sanitizeHeaderValue 移除头部值中的 CR 与 LF,消除头注入向量。
|
||||
func sanitizeHeaderValue(value string) string {
|
||||
return strings.NewReplacer("\r", "", "\n", "").Replace(strings.TrimSpace(value))
|
||||
}
|
||||
|
||||
55
server/internal/notify/email_test.go
Normal file
55
server/internal/notify/email_test.go
Normal file
@@ -0,0 +1,55 @@
|
||||
package notify
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestBuildRawMessageStripsHeaderInjection 验证用户可控内容(如备份任务名
|
||||
// 进入 Subject)中的 CR/LF 被剔除,无法注入额外头部或伪造正文。
|
||||
func TestBuildRawMessageStripsHeaderInjection(t *testing.T) {
|
||||
msg := Message{
|
||||
Title: "备份失败\r\nBcc: attacker@evil.com\r\n\r\n伪造正文",
|
||||
Body: "正文第一行\n正文第二行",
|
||||
}
|
||||
raw := string(buildRawMessage("sender@example.com", []string{"ops@example.com"}, msg))
|
||||
|
||||
parts := strings.SplitN(raw, "\r\n\r\n", 2)
|
||||
if len(parts) != 2 {
|
||||
t.Fatalf("缺少头部/正文分隔符,原文=%q", raw)
|
||||
}
|
||||
headerBlock, body := parts[0], parts[1]
|
||||
|
||||
// 头部区不得出现独立的注入头行。
|
||||
for _, line := range strings.Split(headerBlock, "\r\n") {
|
||||
if strings.HasPrefix(line, "Bcc:") {
|
||||
t.Fatalf("检测到头注入:出现独立 Bcc 头行 %q", line)
|
||||
}
|
||||
}
|
||||
// 头部区应恰好是固定的 5 行(From/To/Subject/MIME-Version/Content-Type)。
|
||||
if got := len(strings.Split(headerBlock, "\r\n")); got != 5 {
|
||||
t.Fatalf("头部行数=%d,期望 5;headerBlock=%q", got, headerBlock)
|
||||
}
|
||||
// 正文必须保持原样(正文中的 \n 合法,不应被处理)。
|
||||
if body != "正文第一行\n正文第二行" {
|
||||
t.Fatalf("正文被篡改:%q", body)
|
||||
}
|
||||
// Subject 行必须包含原始标题文本(CRLF 被移除后拼接在同一行)。
|
||||
if !strings.Contains(headerBlock, "Subject: 备份失败Bcc: attacker@evil.com伪造正文") {
|
||||
t.Fatalf("Subject 行不符合预期:%q", headerBlock)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeHeaderValue(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
" normal ": "normal",
|
||||
"a\r\nb": "ab",
|
||||
"x\ny\rz": "xyz",
|
||||
"no-control-chars": "no-control-chars",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := sanitizeHeaderValue(in); got != want {
|
||||
t.Errorf("sanitizeHeaderValue(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
@@ -21,9 +22,18 @@ func (n *WebhookNotifier) Type() string { return "webhook" }
|
||||
func (n *WebhookNotifier) SensitiveFields() []string { return []string{"secret"} }
|
||||
|
||||
func (n *WebhookNotifier) Validate(config map[string]any) error {
|
||||
if strings.TrimSpace(asString(config["url"])) == "" {
|
||||
raw := strings.TrimSpace(asString(config["url"]))
|
||||
if raw == "" {
|
||||
return fmt.Errorf("webhook url is required")
|
||||
}
|
||||
parsed, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
return fmt.Errorf("webhook url is invalid: %w", err)
|
||||
}
|
||||
// 仅允许 http/https,杜绝 file://、gopher:// 等可被用于 SSRF 的协议。
|
||||
if parsed.Scheme != "http" && parsed.Scheme != "https" {
|
||||
return fmt.Errorf("webhook url must use http or https scheme")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -293,7 +293,7 @@ func (s *AuthService) verifyLoginMFA(ctx context.Context, user *model.User, inpu
|
||||
}
|
||||
|
||||
func (s *AuthService) userBySubject(ctx context.Context, subject string) (*model.User, error) {
|
||||
userID, err := strconv.ParseUint(subject, 10, 64)
|
||||
userID, err := strconv.ParseUint(subject, 10, 0)
|
||||
if err != nil {
|
||||
return nil, apperror.Unauthorized("AUTH_INVALID_SUBJECT", "无效用户身份", err)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user