c20ff56e4a
manifest.AppName / HDDSubdirs / VolumeNames are attacker-controlled JSON inside an imported .fab and reach filepath.Join+MkdirAll/extractTar with a trusted base (restore.go:339/606/678). UnmarshalManifest did zero validation, so '../..' in any of them escaped the stacks / HDD destination dir. - New appexport.ValidateSegment + validateManifestPaths; UnmarshalManifest now fails the parse on a traversal segment (the chokepoint). - Defence-in-depth ValidateSegment guards at the HDD-subdir and volume-name join loops in restore.go. - ConfigFiles deliberately NOT validated (holds dotfiles like .felhom.yml; never used in a restore join). - Permanent regression test (was the deep-sweep failing audit test) now asserts rejection of traversal + acceptance of legit names. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
83 lines
2.9 KiB
Go
83 lines
2.9 KiB
Go
package appexport
|
|
|
|
import (
|
|
"encoding/json"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// Regression test for [CTRL-001] (path traversal on .fab import). Originated as
|
|
// a failing audit test (audit/2026-06-13-deep-sweep); now a permanent guard.
|
|
// UnmarshalManifest must REJECT any manifest whose AppName / HDDSubdirs /
|
|
// VolumeNames contain a path-traversal or separator, and ACCEPT legitimate
|
|
// single-segment names. Do NOT weaken these assertions.
|
|
|
|
func mustManifestJSON(t *testing.T, m Manifest) []byte {
|
|
t.Helper()
|
|
b, err := json.Marshal(m)
|
|
if err != nil {
|
|
t.Fatalf("marshal: %v", err)
|
|
}
|
|
return b
|
|
}
|
|
|
|
func TestUnmarshalManifestRejectsTraversal(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
m Manifest
|
|
}{
|
|
{"appname-parent", Manifest{Version: 1, AppName: "../evil"}},
|
|
{"appname-deep", Manifest{Version: 1, AppName: "../../etc/cron.d/x"}},
|
|
{"appname-absolute", Manifest{Version: 1, AppName: "/etc/cron.d/x"}},
|
|
{"appname-dotdot", Manifest{Version: 1, AppName: ".."}},
|
|
{"appname-empty", Manifest{Version: 1, AppName: ""}},
|
|
{"appname-backslash", Manifest{Version: 1, AppName: `..\evil`}},
|
|
{"hdd-subdir-escape", Manifest{Version: 1, AppName: "romm", HDDSubdirs: []string{"../../mnt"}}},
|
|
{"volume-escape", Manifest{Version: 1, AppName: "romm", VolumeNames: []string{"../../var/lib"}}},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
_, err := UnmarshalManifest(mustManifestJSON(t, tc.m))
|
|
if err == nil {
|
|
t.Fatalf("CTRL-001 regression: UnmarshalManifest accepted a traversal manifest %+v; expected rejection", tc.m)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestUnmarshalManifestAcceptsLegitNames(t *testing.T) {
|
|
m := Manifest{
|
|
Version: 1,
|
|
AppName: "paperless-ngx",
|
|
HDDSubdirs: []string{"felhom-usb", "romm"},
|
|
VolumeNames: []string{"adventurelog_postgres_data", "romm_redis-data"},
|
|
ConfigFiles: []string{".felhom.yml", "docker-compose.yml", "app.yaml"}, // dotfiles must NOT be rejected
|
|
}
|
|
got, err := UnmarshalManifest(mustManifestJSON(t, m))
|
|
if err != nil {
|
|
t.Fatalf("CTRL-001 regression: UnmarshalManifest rejected a legitimate manifest: %v", err)
|
|
}
|
|
if got.AppName != "paperless-ngx" {
|
|
t.Fatalf("AppName round-trip mismatch: %q", got.AppName)
|
|
}
|
|
}
|
|
|
|
func TestValidateSegment(t *testing.T) {
|
|
good := []string{"romm", "paperless-ngx", "adventurelog_postgres_data", "felhom-usb", "a", "App1.2_3-4"}
|
|
for _, s := range good {
|
|
if err := ValidateSegment("x", s); err != nil {
|
|
t.Errorf("ValidateSegment(%q) = %v; want nil", s, err)
|
|
}
|
|
}
|
|
bad := []string{"", ".", "..", "../x", "a/b", `a\b`, "/abs", ".hidden", "-leadingdash", "a/../b"}
|
|
for _, s := range bad {
|
|
if err := ValidateSegment("x", s); err == nil {
|
|
t.Errorf("ValidateSegment(%q) = nil; want rejection", s)
|
|
}
|
|
}
|
|
// Sanity: a rejected value's message names the kind, for operator clarity.
|
|
if err := ValidateSegment("app_name", "../x"); err == nil || !strings.Contains(err.Error(), "app_name") {
|
|
t.Errorf("expected error mentioning app_name, got %v", err)
|
|
}
|
|
}
|