R-126: refuse a .fab export without a password to a network drive (09 decision 128)

An export to a registered network drive (Kind=network) with no bundle
password now answers 400 with a household sentence (hu+en); with a
password it runs; a local drive is unchanged.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
2026-10-05 21:25:42 +02:00
parent 034a2b7363
commit 33c397380e
5 changed files with 120 additions and 0 deletions
+1
View File
@@ -74,6 +74,7 @@
"app_export.titkositasi_jelszo": "Encryption password",
"app_export.valassz_tarolot": "Choose a storage...",
"app_export.valaszthato_tartalom": "Optional content:",
"app_export.network_drive_needs_password": "A bundle can go onto a network drive (NAS) only with a password, because it also holds the passwords of the app. Set a password, or choose an attached drive.",
"app_import.a_feltoltes_megszakadt_ellenorizze_a": "The upload stopped — check the connection, then try again.",
"app_import.adatbazis": "Database:",
"app_import.adatok": "Data:",
+1
View File
@@ -70,6 +70,7 @@
"app_export.titkositasi_jelszo": "Titkosítási jelszó",
"app_export.valassz_tarolot": "Válassz tárolót...",
"app_export.valaszthato_tartalom": "Választható tartalom:",
"app_export.network_drive_needs_password": "Hálózati tárhelyre (NAS) csak jelszóval védett csomagot menthetsz, mert a csomag az alkalmazás jelszavait is tartalmazza. Adj meg jelszót, vagy válassz csatlakoztatott meghajtót.",
"app_import.a_feltoltes_megszakadt_ellenorizze_a": "A feltöltés megszakadt — ellenőrizze a kapcsolatot, majd próbálja újra.",
"app_import.adatbazis": "Adatbázis:",
"app_import.adatok": "Adatok:",
+12
View File
@@ -204,6 +204,18 @@ func (s *Server) apiExportStart(w http.ResponseWriter, r *http.Request) {
return
}
// R-126 (operator ruling 2026-10-05, 09 §3 decision 128): a `.fab` carries the app's secrets in
// plaintext unless a bundle password is set, and a network drive is reachable by every device on
// the household's LAN. So an export WITHOUT a password to any network drive is refused; with a
// password it proceeds. The destination is already known to be registered (isValidDrivePath), so
// IsNetworkStoragePath classifies it by its Kind — the only discriminator (see RefuseAsAppNamespace).
// Pinned by TestExportStart_NetworkDriveNeedsPassword.
if req.Password == "" && s.settings.IsNetworkStoragePath(req.DestDrive) {
s.logger.Printf("[INFO] [web] apiExportStart: refused — no password for network drive %q (stack=%s)", req.DestDrive, req.StackName)
jsonError(w, s.msg(r, "app_export.network_drive_needs_password"), http.StatusBadRequest)
return
}
err := s.appExporter.StartExport(appexport.ExportRequest{
StackName: req.StackName,
DestDrive: req.DestDrive,
@@ -0,0 +1,105 @@
package web
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/appbackup"
"gitea.dooplex.hu/admin/felhom-controller/internal/appexport"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
)
// R-126 (operator ruling 2026-10-05, 09 §3 decision 128): an export WITHOUT a bundle password to a
// network drive is refused; WITH a password it runs; a local drive without a password is unchanged.
// The assertions are the consequence — whether a .fab lands on the destination — not only the status.
func TestExportStart_NetworkDriveNeedsPassword(t *testing.T) {
build := func(t *testing.T, kind string) (*Server, *appexport.Exporter, string) {
s := testServer(t)
s.cfg.Paths.DataDir = t.TempDir()
drive := t.TempDir()
stackDir := t.TempDir()
os.WriteFile(filepath.Join(stackDir, "docker-compose.yml"), []byte("services: {}\n"), 0644)
fabWrite(t, drive, "userdata/media/books/a.epub", "BOOK")
prov := &fabWebProvider{stackDir: stackDir, stacksDir: t.TempDir(), hddPath: drive,
binds: []appbackup.ClassifiedBind{
{ComposeBind: appbackup.ComposeBind{Root: appbackup.RootUserdata, RelPath: "media/books"}, Class: appbackup.ClassMandatory},
}}
e := appexport.NewExporter(prov, s.logger, "test")
s.appExporter = e
if err := s.settings.AddStoragePath(settings.StoragePath{Path: drive, Label: "d", Kind: kind, Schedulable: true}); err != nil {
t.Fatal(err)
}
if got := s.settings.IsNetworkStoragePath(drive); got != (kind == settings.StorageKindNetwork) {
t.Fatalf("fixture: IsNetworkStoragePath(%q)=%v for kind %q", drive, got, kind)
}
return s, e, drive
}
start := func(s *Server, drive, password string) (*httptest.ResponseRecorder, map[string]interface{}) {
body, _ := json.Marshal(map[string]interface{}{"stack_name": "calibre-web", "dest_drive": drive, "password": password})
rr := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/export/start", strings.NewReader(string(body)))
req.Header.Set("Content-Type", "application/json")
s.apiExportStart(rr, req)
var resp map[string]interface{}
json.Unmarshal(rr.Body.Bytes(), &resp)
return rr, resp
}
fabCount := func(dir string) int {
n := 0
filepath.Walk(dir, func(p string, info os.FileInfo, err error) error {
if err == nil && strings.HasSuffix(p, ".fab") {
n++
}
return nil
})
return n
}
t.Run("network drive, no password: refused, nothing written", func(t *testing.T) {
s, e, drive := build(t, settings.StorageKindNetwork)
rr, resp := start(s, drive, "")
if rr.Code != http.StatusBadRequest || resp["ok"] != false {
t.Fatalf("want 400 ok=false, got %d %s", rr.Code, rr.Body.String())
}
msg, _ := resp["error"].(string)
// ASCII fragments of the Hungarian sentence (positive), and never the raw key (negative).
if !strings.Contains(msg, "(NAS)") || !strings.Contains(msg, "jelsz") || strings.Contains(msg, "app_export.") {
t.Errorf("refusal text is not the household sentence: %q", msg)
}
if j := e.GetActiveJob(); j != nil {
t.Errorf("an export job started despite the refusal: %v", j.Snapshot())
}
if n := fabCount(drive); n != 0 {
t.Errorf("%d .fab file(s) landed on the network drive without a password", n)
}
})
t.Run("network drive, with password: runs", func(t *testing.T) {
s, e, drive := build(t, settings.StorageKindNetwork)
rr, resp := start(s, drive, "correct horse battery")
if rr.Code != http.StatusOK || resp["ok"] != true {
t.Fatalf("want 200 ok=true, got %d %s", rr.Code, rr.Body.String())
}
waitExportDone(t, e)
if n := fabCount(drive); n != 1 {
t.Errorf("want 1 .fab on the network drive, got %d", n)
}
})
t.Run("local drive, no password: unchanged", func(t *testing.T) {
s, e, drive := build(t, "")
rr, resp := start(s, drive, "")
if rr.Code != http.StatusOK || resp["ok"] != true {
t.Fatalf("want 200 ok=true, got %d %s", rr.Code, rr.Body.String())
}
waitExportDone(t, e)
if n := fabCount(drive); n != 1 {
t.Errorf("want 1 .fab on the local drive, got %d", n)
}
})
}
+1
View File
@@ -8,6 +8,7 @@
"banner.missed_backup.suggest": "BORN AS A KEY, v0.295.0 (R-871, `09` decision 110) -- a NEW sentence of the missed-backup banner, never a Go literal; pinned in Hungarian by TestR871_BannerShownThenClosedUntilTheNextMiss and the parity fixture launcher_missed_backup.",
"event.backup_catchup_done": "BORN AS A KEY, v0.295.0 (R-871, `09` decision 109) -- the catch-up's NEW timeline line, never a Go literal; sent by Notifier.NotifyBackupCatchUp (wiring pinned by TestR871_CatchUpWiring).",
"badge.lifecycle.abandoned": "R-589 (v0.258.0) -- localeFuncs; the Hungarian form stays in templateFuncMap, pinned by TestLocaleFuncsHungarianBundleMatchesFuncMap",
"app_export.network_drive_needs_password": "BORN AS A KEY (R-126, `09` decision 128) -- a NEW sentence, never a Go literal: the export refusal for a network drive without a bundle password. Pinned in Hungarian by TestExportStart_NetworkDriveNeedsPassword.",
"badge.lifecycle.abandoned.title": "R-589 (v0.258.0) -- localeFuncs; the Hungarian form stays in templateFuncMap, pinned by TestLocaleFuncsHungarianBundleMatchesFuncMap",
"badge.update.ahead.title": "BORN AS A KEY, v0.260.0 (R-524) -- a NEW sentence, never a Go literal, so there is nothing in the base capture to measure it against. Pinned in both languages by TestUpdateBadgeFollowsTheLanguage.",
"badge.update.behind": "R-589 (v0.258.0) -- localeFuncs; the Hungarian form stays in templateFuncMap, pinned by TestLocaleFuncsHungarianBundleMatchesFuncMap",