v0.76.0: campaign-#3 hardening (settings .bak recovery, restore stack_name validation, quiesce marker quarantine)

S1: corrupt settings.json recovers from .bak / safe-defaults+preserve, no crash-loop.
F2: validStackName gates restore + export handlers (reject /,\,..,NUL traversal).
S3: corrupt quiesce marker logged + quarantined, not silently dropped.
Tests T-S1/F2/S3 + red-proofs. Agent/hub untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-22 23:34:33 +02:00
parent 141d51bf19
commit b0dd13154b
10 changed files with 305 additions and 2 deletions
@@ -166,6 +166,11 @@ func (s *Server) apiExportStart(w http.ResponseWriter, r *http.Request) {
jsonError(w, "Missing stack_name or dest_drive", http.StatusBadRequest)
return
}
// F2 (defense-in-depth): reject a traversal/escape stack_name before any export work.
if !validStackName(req.StackName) {
jsonError(w, "Invalid stack_name", http.StatusBadRequest)
return
}
if !s.isValidDrivePath(req.DestDrive) {
s.logger.Printf("[DEBUG] [web] apiExportStart: invalid drive path %q", req.DestDrive)
+8
View File
@@ -151,6 +151,7 @@ func (s *Server) dashboardHandler(w http.ResponseWriter, r *http.Request) {
sysInfo := system.GetInfo(s.primaryHDDPath(), s.cpuCollector)
data := s.baseData("dashboard", "Vezérlőpult")
data["SettingsWarning"] = s.settings.LoadWarning // non-empty if settings.json was recovered from corruption
data["Stacks"] = deployedStacks
data["MissingStorage"] = s.missingStorageMap(deployedStacks)
data["RunningCount"] = running
@@ -795,6 +796,13 @@ func (s *Server) backupRestoreHandler(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/backups?flash_error=Hi%C3%A1nyz%C3%B3+param%C3%A9terek", http.StatusFound)
return
}
// F2 (defense-in-depth): a stack name is a single segment, never a path. Reject traversal before any
// restore work — never let it reach RestoreFromRecoveryUnit.
if !validStackName(stackName) {
s.logger.Printf("[WARN] [web] restore rejected: invalid stack_name %q from %s", stackName, r.RemoteAddr)
http.Redirect(w, r, "/backups?flash_error=%C3%89rv%C3%A9nytelen+alkalmaz%C3%A1sn%C3%A9v", http.StatusFound)
return
}
if s.backupMgr == nil {
http.Redirect(w, r, "/backups?flash_error=Ment%C3%A9s+nincs+be%C3%A1ll%C3%ADtva", http.StatusFound)
@@ -87,6 +87,14 @@
{{end}}
{{end}}
</div>
{{if .SettingsWarning}}
<div class="inline-warnings">
<div class="inline-warning inline-warning-error">
<span class="inline-warning-dot"></span>
<span class="inline-warning-text">{{.SettingsWarning}}</span>
</div>
</div>
{{end}}
{{if .DiskWarnings}}
<div class="inline-warnings">
{{range .DiskWarnings}}
+19
View File
@@ -0,0 +1,19 @@
package web
import (
"path/filepath"
"strings"
)
// validStackName reports whether name is a safe stack identifier: a single path segment, never a path.
// Rejects traversal/escape (`..`, `/`, `\`, NUL) so a stack_name can never be turned into a filesystem
// path that escapes the stacks/userdata tree. (Storage `where=` is validated separately by gateWhere.)
func validStackName(name string) bool {
if name == "" || name == "." || name == ".." {
return false
}
if strings.ContainsAny(name, "/\\\x00") {
return false
}
return name == filepath.Clean(name)
}
+61
View File
@@ -0,0 +1,61 @@
package web
import (
"io"
"log"
"net/http/httptest"
"strings"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/appexport"
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
)
// T-F2a: validStackName rejects traversal/escape, accepts real single-segment names. (pure)
func TestValidStackName(t *testing.T) {
for _, bad := range []string{"../../etc", "a/b", "..", ".", "", "a\x00b", "a\\b", "../x", "/etc"} {
if validStackName(bad) {
t.Errorf("validStackName(%q) = true, want false", bad)
}
}
for _, good := range []string{"paperless-ngx", "immich", "romm", "uptime-kuma"} {
if !validStackName(good) {
t.Errorf("validStackName(%q) = false, want true", good)
}
}
}
// T-F2b: the restore handler rejects a traversal stack_name with the flash-error redirect and never
// reaches RestoreFromRecoveryUnit (backupMgr is nil — if the gate failed to fire, it would nil-panic
// or fall through to the "Mentés nincs beállítva" path, not the "Érvénytelen" one).
// Companion: drop the gate → traversal falls through to the nil-backupMgr path → different redirect → fails.
func TestBackupRestoreHandler_RejectsTraversal(t *testing.T) {
s := &Server{cfg: &config.Config{}, logger: log.New(io.Discard, "", 0)} // backupMgr == nil
form := "stack_name=../../../etc&snapshot_id=x"
req := httptest.NewRequest("POST", "/backup/restore", strings.NewReader(form))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rec := httptest.NewRecorder()
s.backupRestoreHandler(rec, req) // must not panic
loc := rec.Header().Get("Location")
if !strings.Contains(loc, "rv%C3%A9nytelen") { // "Érvénytelen" (invalid name) — the gate's redirect
t.Fatalf("expected traversal rejection redirect, got Location=%q", loc)
}
}
// T-F2c: the export handler rejects a traversal stack_name with 400 (before any export work).
func TestExportStart_RejectsTraversal(t *testing.T) {
// non-nil exporter so we pass the "not available" guard and reach the stack_name gate (the gate
// returns before the exporter is ever used).
s := &Server{cfg: &config.Config{}, logger: log.New(io.Discard, "", 0), appExporter: &appexport.Exporter{}}
body := `{"stack_name":"../../etc","dest_drive":"/mnt/x"}`
req := httptest.NewRequest("POST", "/api/export/start", strings.NewReader(body))
rec := httptest.NewRecorder()
s.apiExportStart(rec, req) // must not panic
if rec.Code != 400 {
t.Fatalf("expected 400 for traversal stack_name, got %d", rec.Code)
}
}