Files
felhom-controller/controller/internal/privpolicy/privpolicy_test.go
T
admin 03ab121b25
gates / gates (push) Successful in 46s
v0.329.0 (review): an app named like another app's appdata folder gets no root; USERDATA_PATH is always derived, never the app's env
Security review of 2fe6341 (same night, before any release). Both red-proofed.
2026-10-17 23:22:54 +02:00

552 lines
25 KiB
Go

package privpolicy
import (
"bytes"
"errors"
"os"
"path/filepath"
"strings"
"testing"
)
var pol = Policy{SystemDataPath: "/var/lib/felhom"}
func env() map[string]string {
return map[string]string{
"HDD_PATH": "/mnt/felhom-drives/adatlemez/felhom-data",
"USERDATA_PATH": "/mnt/felhom-drives/adatlemez/felhom-data/userdata",
"IMPORT_PATH": "/var/lib/felhom/data/userdata/import",
"SUBDOMAIN": "x", "DOMAIN": "example.hu",
}
}
// base is a real-shaped app: a root-led bind, a named volume, a hardening security_opt.
const base = `services:
app:
image: example/app:1.0
security_opt:
- no-new-privileges:true
volumes:
- app_data:/data
- ${USERDATA_PATH}/media:/media:ro
- ${HDD_PATH}/appdata/app:/config
- ${IMPORT_PATH}/app:/consume
volumes:
app_data:
`
func admit(t *testing.T, app, compose string, e map[string]string) error {
t.Helper()
return Admit(app, []byte(compose), e, pol)
}
func TestAdmit_TheBaseShapeIsAdmitted(t *testing.T) {
if err := admit(t, "app", base, env()); err != nil {
t.Fatalf("a normal app was refused: %v", err)
}
}
// One ATTACK per class, each added to the admitted base: refused, and the refusal names the setting.
// RED-PROOF (P2/redproof-privpolicy.txt): with Admit returning nil, every case fails.
func TestAdmit_EveryClassIsRefused(t *testing.T) {
cases := map[string]struct{ add, want string }{
"privileged": {" privileged: true\n", "privileged true"},
"cap_add": {" cap_add: [SYS_ADMIN]\n", "cap_add SYS_ADMIN"},
"devices": {" devices: [\"/dev/sda:/dev/sda\"]\n", "device /dev/sda:/dev/sda"},
"device_cgroup_rule": {" device_cgroup_rules: [\"b 8:* rmw\"]\n", "device_cgroup_rule b 8:* rmw"},
"network_mode host": {" network_mode: host\n", "network_mode host"},
"network_mode ctr": {" network_mode: \"container:felhom-controller\"\n", "network_mode container:felhom-controller"},
"pid": {" pid: host\n", "pid host"},
"ipc": {" ipc: host\n", "ipc host"},
"uts": {" uts: host\n", "uts host"},
"userns_mode": {" userns_mode: host\n", "userns_mode host"},
"cgroup": {" cgroup: host\n", "cgroup host"},
"seccomp off": {" security_opt: [\"seccomp=unconfined\"]\n", "security_opt seccomp=unconfined"},
"apparmor off": {" security_opt: [\"apparmor:unconfined\"]\n", "security_opt apparmor:unconfined"},
"sysctls": {" sysctls:\n kernel.core_pattern: x\n", "sysctl kernel.core_pattern=x"},
"volumes_from": {" volumes_from: [\"container:felhom-controller\"]\n", "volumes_from container:felhom-controller"},
"build": {" build: /\n", "build"},
"env_file outside": {" env_file: /opt/docker/felhom-controller/controller.yaml\n", "env_file /opt/docker/felhom-controller/controller.yaml"},
"extends file": {" extends:\n file: /etc/x.yml\n service: y\n", "extends_file /etc/x.yml"},
"interpolated": {" privileged: ${PRIV}\n", "interpolated privileged"},
}
for name, c := range cases {
t.Run(name, func(t *testing.T) {
compose := strings.Replace(base, " image: example/app:1.0\n", " image: example/app:1.0\n"+c.add, 1)
// security_opt is already in base: replace it rather than duplicate the key
if strings.HasPrefix(c.add, " security_opt") {
compose = strings.Replace(base, " security_opt:\n - no-new-privileges:true\n", c.add, 1)
}
err := admit(t, "app", compose, env())
var re *RefusedError
if !errors.As(err, &re) {
t.Fatalf("not refused: %v", err)
}
if !strings.Contains(err.Error(), c.want) {
t.Fatalf("refusal %q does not name %q", err, c.want)
}
})
}
}
// Bind sources: the root rule, read on the TEMPLATE and on the RENDERED value.
func TestAdmit_Binds(t *testing.T) {
refuse := map[string]string{
"host root": " - /:/host",
"docker socket": " - /var/run/docker.sock:/var/run/docker.sock",
"socket under a root": " - ${HDD_PATH}/docker.sock:/s",
"etc": " - /etc:/etc:ro",
"relative stack dir": " - ./:/stack",
"dotdot after a root": " - ${HDD_PATH}/../../../etc:/x",
"another variable": " - ${OTHER}/x:/x",
"root then a variable": " - ${HDD_PATH}${OTHER}:/x",
"rshared propagation": " - ${HDD_PATH}/x:/x:rshared",
"long syntax socket": " - type: bind\n source: /run/docker.sock\n target: /s",
"long syntax npipe": " - type: npipe\n source: x\n target: /s",
"literal outside roots": " - /mnt/other-drive/x:/x",
}
for name, line := range refuse {
t.Run("refused/"+name, func(t *testing.T) {
compose := strings.Replace(base, " - app_data:/data\n", " - app_data:/data\n"+line+"\n", 1)
if err := admit(t, "app", compose, env()); err == nil {
t.Fatalf("%s was admitted", line)
}
})
}
// Positive controls: a literal path INSIDE a root, a long-syntax root bind, a tmpfs.
for name, line := range map[string]string{
"literal inside a root": " - /mnt/felhom-drives/adatlemez/felhom-data/userdata/x:/x",
"long syntax root bind": " - type: bind\n source: ${USERDATA_PATH}/media\n target: /m\n read_only: true",
"tmpfs": " - type: tmpfs\n target: /tmp",
} {
t.Run("admitted/"+name, func(t *testing.T) {
compose := strings.Replace(base, " - app_data:/data\n", " - app_data:/data\n"+line+"\n", 1)
if err := admit(t, "app", compose, env()); err != nil {
t.Fatalf("%s was refused: %v", line, err)
}
})
}
}
// The ROOTS are checked too: a HDD_PATH of "/" (or anything not under /mnt/ or the system data path) is no root, so
// every bind under it is refused — env cannot widen the box's data roots.
func TestAdmit_ARootValueOutsideTheBoxIsNoRoot(t *testing.T) {
for _, bad := range []string{"/", "/etc", "/mnt/../etc", "relative/x", "/var/lib/felhomX"} {
e := env()
e["HDD_PATH"] = bad
if err := admit(t, "app", base, e); err == nil {
t.Fatalf("HDD_PATH=%q was accepted as a data root", bad)
}
}
e := env()
e["HDD_PATH"] = "/var/lib/felhom/data" // the system-data fallback is a root
if err := admit(t, "app", base, e); err != nil {
t.Fatalf("the system-data root was refused: %v", err)
}
}
// Top-level definitions that can alias a host path or another stack's volume.
func TestAdmit_TopLevelAliases(t *testing.T) {
for name, tail := range map[string]string{
"driver_opts bind": " app_data:\n driver_opts:\n type: none\n o: bind\n device: /\n",
"external": " app_data:\n external: true\n",
"explicit name": " app_data:\n name: felhom-controller_data\n",
"plugin driver": " app_data:\n driver: rexray\n",
} {
t.Run(name, func(t *testing.T) {
compose := strings.Replace(base, "volumes:\n app_data:\n", "volumes:\n"+tail, 1)
if err := admit(t, "app", compose, env()); err == nil {
t.Fatal("admitted")
}
})
}
for name, add := range map[string]string{
"include": "include:\n - /etc/other.yml\n",
"config file": "configs:\n c:\n file: /etc/shadow\n",
"secret file": "secrets:\n s:\n file: ../../x\n",
} {
t.Run(name, func(t *testing.T) {
if err := admit(t, "app", base+add, env()); err == nil {
t.Fatal("admitted")
}
})
}
if err := admit(t, "app", base+"configs:\n c:\n file: ./conf/app.ini\n", env()); err != nil {
t.Fatalf("a config file inside the stack dir was refused: %v", err)
}
}
// testAllowList is the shape the box had until R-981 (one entry, homepage's read-only socket). The compiled list is
// empty since then; the entry-binding rules are still pinned on this test list, so a future entry is held to them.
const testAllowList = `{"version":2,"entries":[{"app":"homepage","service":"homepage",
"setting":"bind /var/run/docker.sock -> /var/run/docker.sock ro","image":"ghcr.io/gethomepage/homepage",
"healthcheck":"[\"CMD\",\"wget\",\"--spider\",\"-q\",\"http://127.0.0.1:3000\"]"}],
"appdata_folders":{"paperless-ngx":"paperless"}}`
// withTestAllowList swaps the compiled list for testAllowList for one test.
func withTestAllowList(t *testing.T) {
t.Helper()
oldA, oldF := allowed, appdataFolders
allowed, appdataFolders = mustLoad([]byte(testAllowList))
t.Cleanup(func() { allowed, appdataFolders = oldA, oldF })
}
const homepage = `services:
homepage:
image: ghcr.io/gethomepage/homepage:v1.13.2
volumes:
- homepage_config:/app/config
- /var/run/docker.sock:/var/run/docker.sock:ro
healthcheck:
test: ["CMD", "wget", "--spider", "-q", "http://127.0.0.1:3000"]
volumes:
homepage_config:
`
// The allow-list is EXACT: homepage's read-only socket is allowed for homepage's service only; the same socket
// read-write, under another service, or in another app is refused.
func TestAdmit_TheAllowListIsExact(t *testing.T) {
withTestAllowList(t)
if err := admit(t, "homepage", homepage, env()); err != nil {
t.Fatalf("the allowed homepage socket was refused: %v", err)
}
for name, c := range map[string]struct{ app, compose string }{
"read-write": {"homepage", strings.Replace(homepage, "docker.sock:ro", "docker.sock", 1)},
"another service": {"homepage", strings.Replace(homepage, " homepage:\n image", " sidecar:\n image", 1)},
"another app": {"glance", homepage},
"plus privileged": {"homepage", strings.Replace(homepage, " volumes:", " privileged: true\n volumes:", 1)},
} {
t.Run(name, func(t *testing.T) {
if err := admit(t, c.app, c.compose, env()); err == nil {
t.Fatal("admitted")
}
})
}
}
// The check fails CLOSED: a compose it cannot read is refused, not admitted.
func TestAdmit_UnreadableIsRefused(t *testing.T) {
if err := admit(t, "app", "services: [::", env()); err == nil {
t.Fatal("an unparseable compose was admitted")
}
}
// The renderer follows compose's forms.
func TestRender_ComposeForms(t *testing.T) {
c := &checker{env: map[string]string{"A": "a", "E": ""}}
for in, want := range map[string]string{
"${A}": "a", "$A/x": "a/x", "${B:-d}": "d", "${E:-d}": "d", "${E-d}": "", "${B-d}": "d",
"${A:+y}": "y", "${E:+y}": "", "${E+y}": "y", "$$A": "$A", "${A:?boom}": "a",
} {
if got := c.render(in); got != want {
t.Errorf("render(%q) = %q, want %q", in, got, want)
}
}
}
// Every template and ladder step in the catalog passes on a placeholder env — the catalog as it is today needs
// exactly the allow-list it has. Runs where the sibling clone is present (the workspace); CI has none.
func TestAllowList_TheWholeCatalogPasses(t *testing.T) {
root := filepath.Join("..", "..", "..", "..", "app-catalog-felhom.eu", "templates")
if _, err := os.Stat(root); err != nil {
t.Skipf("no sibling catalog clone at %s — checked in the workspace, not in CI", root)
}
n := 0
_ = filepath.Walk(root, func(p string, info os.FileInfo, err error) error {
if err != nil || info.IsDir() {
return nil
}
if info.Name() != "docker-compose.yml" && !(filepath.Base(filepath.Dir(p)) == "steps" && strings.HasSuffix(p, ".yml")) {
return nil
}
n++
rel, _ := filepath.Rel(root, p)
app := strings.Split(rel, string(filepath.Separator))[0]
b, _ := os.ReadFile(p)
if err := Admit(app, b, PlaceholderEnv(), pol); err != nil {
t.Errorf("%s: %v", rel, err)
}
return nil
})
if n < 100 {
t.Fatalf("only %d catalog files found — the walk did not see the catalog", n)
}
}
// The catalog's CI copy of the list is byte-identical (the box's copy is the authority; the catalog's is an early
// warning). Where the sibling is absent (CI), the catalog's own gate runs the same comparison when it can.
func TestAllowList_CatalogCopyIsIdentical(t *testing.T) {
p := filepath.Join("..", "..", "..", "..", "app-catalog-felhom.eu", "scripts", "privilege-allowlist.json")
b, err := os.ReadFile(p)
if err != nil {
t.Skipf("no sibling catalog clone (%v) — checked in the workspace, not in CI", err)
}
if !bytes.Equal(b, AllowListJSON()) {
t.Fatalf("%s differs from internal/privpolicy/allowlist.json — change both in the same session", p)
}
}
// Security review 2026-10-17, finding 1: an allow-list entry holds only for its image and the image's own start. The
// same allowed socket under a swapped image, or with an entrypoint or command added, is refused.
func TestAdmit_TheAllowedSettingIsBoundToItsImageAndStart(t *testing.T) {
withTestAllowList(t)
for name, compose := range map[string]string{
"another image": strings.Replace(homepage, "ghcr.io/gethomepage/homepage:v1.13.2", "docker.io/evil/homepage:v1.13.2", 1),
"added entrypoint": strings.Replace(homepage, " volumes:", " entrypoint: [\"sh\", \"-c\", \"id\"]\n volumes:", 1),
"added command": strings.Replace(homepage, " volumes:", " command: sh -c id\n volumes:", 1),
} {
t.Run(name, func(t *testing.T) {
if err := admit(t, "homepage", compose, env()); err == nil {
t.Fatal("admitted")
}
})
}
// Positive control: a new TAG or a digest of the same repository is still homepage.
bumped := strings.Replace(homepage, "homepage:v1.13.2", "homepage:v1.14.0@sha256:"+strings.Repeat("a", 64), 1)
if err := admit(t, "homepage", bumped, env()); err != nil {
t.Fatalf("a version bump of the allowed image was refused: %v", err)
}
}
// Finding 2: on the box, a bind source inside a root that is a symlink OUT of it is outside.
func TestAdmit_ASymlinkOutOfARootIsOutside(t *testing.T) {
root := t.TempDir()
ns := filepath.Join(root, "mnt", "felhom-drives", "d", "felhom-data")
if err := os.MkdirAll(filepath.Join(ns, "userdata", "media"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.Symlink("/etc", filepath.Join(ns, "userdata", "escape")); err != nil {
t.Fatal(err)
}
// The roots must be under the drive namespace by NAME; the temp dir stands in via a Resolve that maps the names.
e := map[string]string{"HDD_PATH": "/mnt/felhom-drives/d/felhom-data", "USERDATA_PATH": "/mnt/felhom-drives/d/felhom-data/userdata"}
p := Policy{Resolve: func(s string) (string, error) {
r, err := filepath.EvalSymlinks(filepath.Join(root, s))
if err != nil {
return "", err
}
if rel, rerr := filepath.Rel(root, r); rerr == nil && !strings.HasPrefix(rel, "..") {
return "/" + rel, nil
}
return r, nil
}}
esc := "services:\n app:\n image: x/y:1\n volumes:\n - ${USERDATA_PATH}/escape:/x\n"
if err := Admit("app", []byte(esc), e, p); err == nil {
t.Fatal("a symlink to /etc inside the userdata root was admitted")
}
ok := "services:\n app:\n image: x/y:1\n volumes:\n - ${USERDATA_PATH}/media:/m\n - ${USERDATA_PATH}/not-yet:/n\n"
if err := Admit("app", []byte(ok), e, p); err != nil {
t.Fatalf("a real directory (and one not created yet) inside the root was refused: %v", err)
}
}
// Finding 3: the classes the first version missed.
func TestAdmit_TheLaterClasses(t *testing.T) {
for name, add := range map[string]string{
"cgroup_parent": " cgroup_parent: /\n",
"runtime": " runtime: runc-unsafe\n",
"gpus": " gpus: all\n",
"device request": " deploy:\n resources:\n reservations:\n devices:\n - capabilities: [gpu]\n",
"provider": " provider:\n type: model\n",
"post_start": " post_start:\n - command: id\n privileged: true\n",
} {
t.Run(name, func(t *testing.T) {
compose := strings.Replace(base, " image: example/app:1.0\n", " image: example/app:1.0\n"+add, 1)
if err := admit(t, "app", compose, env()); err == nil {
t.Fatal("admitted")
}
})
}
}
// Finding 4: a data root lives under the drive namespace or the system data path, not anywhere under /mnt.
func TestAdmit_ARootElsewhereUnderMntIsNoRoot(t *testing.T) {
e := env()
e["HDD_PATH"] = "/mnt/sys_other/x"
if err := admit(t, "app", base, e); err == nil {
t.Fatal("a root outside /mnt/felhom-drives/ and the system data path was accepted")
}
}
// Round 2 of the security review: the gaps found in the fixes.
func TestAdmit_Round2(t *testing.T) {
withTestAllowList(t)
refuse := map[string]struct{ app, compose string }{
"a second service extends the allowed one": {"homepage", strings.Replace(homepage, "volumes:\n homepage_config:", " evil:\n image: evil/x:1\n extends:\n service: homepage\nvolumes:\n homepage_config:", 1)},
"the allowed service's healthcheck runs a shell": {"homepage", strings.Replace(homepage, `["CMD", "wget", "--spider", "-q", "http://127.0.0.1:3000"]`, `["CMD-SHELL", "id"]`, 1)},
"the allowed service's image through a variable": {"homepage", strings.Replace(homepage, "ghcr.io/gethomepage/homepage:v1.13.2", "${HP_IMAGE:-ghcr.io/gethomepage/homepage:v1.13.2}", 1)},
"a variable in a bind's options": {"app", strings.Replace(base, " - ${HDD_PATH}/appdata/app:/config\n", " - ${HDD_PATH}/appdata/app:/config:${MODE}\n", 1)},
"a non-root variable in a bind's target": {"app", strings.Replace(base, " - ${HDD_PATH}/appdata/app:/config\n", " - ${HDD_PATH}/appdata/app:${TGT}\n", 1)},
}
for name, c := range refuse {
t.Run(name, func(t *testing.T) {
if err := admit(t, c.app, c.compose, env()); err == nil {
t.Fatal("admitted")
}
})
}
// Positive control: the *arr shape — the same root path inside and out — passes.
arr := strings.Replace(base, " - ${HDD_PATH}/appdata/app:/config\n", " - ${USERDATA_PATH}/media:${USERDATA_PATH}/media\n", 1)
if err := admit(t, "app", arr, env()); err != nil {
t.Fatalf("the *arr bind shape was refused: %v", err)
}
}
// Round 2: a path that does not exist yet is judged through its nearest existing ancestor — a symlink above it out of
// the root refuses; a resolve error that is not "does not exist" refuses too.
func TestAdmit_ANotYetCreatedPathBehindASymlink(t *testing.T) {
root := t.TempDir()
ns := filepath.Join(root, "mnt", "felhom-drives", "d", "felhom-data", "userdata")
if err := os.MkdirAll(ns, 0o755); err != nil {
t.Fatal(err)
}
if err := os.Symlink("/etc", filepath.Join(ns, "escape")); err != nil {
t.Fatal(err)
}
e := map[string]string{"USERDATA_PATH": "/mnt/felhom-drives/d/felhom-data/userdata"}
resolve := func(s string) (string, error) {
r, err := filepath.EvalSymlinks(filepath.Join(root, s))
if err != nil {
return "", err
}
if rel, rerr := filepath.Rel(root, r); rerr == nil && !strings.HasPrefix(rel, "..") {
return "/" + rel, nil
}
return r, nil
}
c := "services:\n app:\n image: x/y:1\n volumes:\n - ${USERDATA_PATH}/escape/new-dir:/x\n"
if err := Admit("app", []byte(c), e, Policy{Resolve: resolve}); err == nil {
t.Fatal("a not-yet-created directory behind a symlink to /etc was admitted")
}
broken := func(string) (string, error) { return "", errors.New("permission denied") }
ok := "services:\n app:\n image: x/y:1\n volumes:\n - ${USERDATA_PATH}/media:/m\n"
if err := Admit("app", []byte(ok), e, Policy{Resolve: broken}); err == nil {
t.Fatal("a resolve error other than not-exist was admitted (fail open)")
}
}
// Round 3: ${HDD_PATH} itself and its siblings of appdata/ (backups/ — the recovery units with app secrets) are no
// root; ${HDD_PATH}/appdata/... is.
func TestAdmit_HDDPathRootIsAppdataOnly(t *testing.T) {
for _, line := range []string{" - ${HDD_PATH}:/drive\n", " - ${HDD_PATH}/backups:/b\n", " - ${HDD_PATH}/appdata-x:/b\n"} {
compose := strings.Replace(base, " - app_data:/data\n", " - app_data:/data\n"+line, 1)
if err := admit(t, "app", compose, env()); err == nil {
t.Fatalf("%q was admitted", line)
}
}
}
// Found live on 9201 (rc.4): with Resolve set, roots that do not exist yet (a drive folder not created, or the
// placeholder env) made every bind "outside". A root is resolved through its nearest existing ancestor too.
func TestAdmit_RootsThatDoNotExistYet(t *testing.T) {
root := t.TempDir()
if err := os.MkdirAll(filepath.Join(root, "mnt", "felhom-drives"), 0o755); err != nil {
t.Fatal(err)
}
resolve := func(s string) (string, error) {
r, err := filepath.EvalSymlinks(filepath.Join(root, s))
if err != nil {
return "", err
}
rel, _ := filepath.Rel(root, r)
return "/" + rel, nil
}
c := "services:\n app:\n image: x/y:1\n volumes:\n - ${USERDATA_PATH}/media:/m\n - ${HDD_PATH}/appdata/app:/c\n"
if err := Admit("app", []byte(c), PlaceholderEnv(), Policy{Resolve: resolve}); err != nil {
t.Fatalf("binds under roots that do not exist yet were refused: %v", err)
}
}
// R-981 (2026-10-18): homepage's Docker socket served no Felhom feature (its rendered docker.yaml is empty —
// audits/night-2026-10-18/P1/r981-homepage-before.txt), so the compiled list has NO exception: the socket is refused
// for homepage too, read-only or not. The catalog template no longer mounts it (TheWholeCatalogPasses).
// RED-PROOF: with the old one-entry allowlist.json compiled in, this test fails („homepage's socket was admitted").
func TestAllowList_HasNoExceptionAndRefusesHomepagesSocket(t *testing.T) {
if n := len(Entries()); n != 0 {
t.Fatalf("the compiled allow-list has %d entries; R-981 left it empty", n)
}
if err := admit(t, "homepage", homepage, env()); err == nil {
t.Fatal("homepage's socket was admitted")
}
without := strings.Replace(homepage, " - /var/run/docker.sock:/var/run/docker.sock:ro\n", "", 1)
if err := admit(t, "homepage", without, env()); err != nil {
t.Fatalf("homepage without the socket was refused: %v", err)
}
}
// R-1142: under ${HDD_PATH} an app reaches its OWN folder only — appdata/<its name>/, or the folder the list names
// (paperless-ngx keeps appdata/paperless). Another app's folder, a name that only starts like its own, and a bind
// for an app with no usable name are refused.
// RED-PROOF: with validRoots' root back at ${HDD_PATH}/appdata (the v0.328.0 shape), the three „another app" cases
// are admitted and the test fails.
func TestAdmit_AnotherAppsAppdataFolderIsRefused(t *testing.T) {
bind := func(line string) string {
return "services:\n app:\n image: x/y:1\n volumes:\n - " + line + "\n"
}
refuse := map[string]struct{ app, line string }{
"another app's folder": {"immich", "${HDD_PATH}/appdata/nextcloud:/data"},
"another app's sub-folder": {"romm", "${HDD_PATH}/appdata/paperless/media:/m"},
"the whole appdata": {"immich", "${HDD_PATH}/appdata:/all"},
"a name that starts like its own": {"immich", "${HDD_PATH}/appdata/immich-other:/x"},
"the slug where a folder is set": {"paperless-ngx", "${HDD_PATH}/appdata/paperless-ngx:/x"},
"a literal path to another app": {"immich", "/mnt/felhom-drives/adatlemez/felhom-data/appdata/nextcloud:/x"},
"no app name": {"", "${HDD_PATH}/appdata/x:/x"},
"an app name with a slash": {"a/b", "${HDD_PATH}/appdata/a/b:/x"},
}
for name, c := range refuse {
t.Run("refused/"+name, func(t *testing.T) {
if err := admit(t, c.app, bind(c.line), env()); err == nil {
t.Fatalf("%s binding %s was admitted", c.app, c.line)
}
})
}
for name, c := range map[string]struct{ app, line string }{
"its own folder": {"immich", "${HDD_PATH}/appdata/immich:/usr/src/app/upload"},
"its own sub-folder": {"romm", "${HDD_PATH}/appdata/romm/resources:/romm/resources"},
"the listed folder": {"paperless-ngx", "${HDD_PATH}/appdata/paperless/media:/usr/src/paperless/media"},
"a literal path to its own": {"nextcloud", "/mnt/felhom-drives/adatlemez/felhom-data/appdata/nextcloud:/d"},
"USERDATA is still shared": {"immich", "${USERDATA_PATH}/media/photos:/external/photos:ro"},
} {
t.Run("admitted/"+name, func(t *testing.T) {
if err := admit(t, c.app, bind(c.line), env()); err != nil {
t.Fatalf("%s binding %s was refused: %v", c.app, c.line, err)
}
})
}
}
// The folder map loads only single folder names (a list entry naming a path would hand out a wider root).
func TestAllowList_AppdataFolderMustBeOneName(t *testing.T) {
for _, bad := range []string{"", ".", "..", "a/b", "../x", "${X}"} {
func() {
defer func() {
if recover() == nil {
t.Errorf("appdata folder %q was loaded", bad)
}
}()
mustLoad([]byte(`{"version":2,"entries":[],"appdata_folders":{"app":"` + bad + `"}}`))
}()
}
// Security review 2026-10-18: an app NAMED like a folder the map gives to another app gets no root at all.
if got := AppdataFolder("paperless"); got != "" {
t.Errorf("AppdataFolder(paperless) = %q — a template named like paperless-ngx's folder must not own it", got)
}
if err := admit(t, "paperless", "services:\n app:\n image: x/y:1\n volumes:\n - ${HDD_PATH}/appdata/paperless/media:/m\n", env()); err == nil {
t.Error("a template named paperless was admitted to paperless-ngx's folder")
}
func() {
defer func() {
if recover() == nil {
t.Error("one folder given to two apps was loaded")
}
}()
mustLoad([]byte(`{"version":2,"entries":[],"appdata_folders":{"a":"x","b":"x"}}`))
}()
if got := AppdataFolder("paperless-ngx"); got != "paperless" {
t.Errorf("AppdataFolder(paperless-ngx) = %q", got)
}
if got := AppdataFolder("immich"); got != "immich" {
t.Errorf("AppdataFolder(immich) = %q", got)
}
}