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:
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user