03ab121b25
gates / gates (push) Successful in 46s
Security review of 2fe6341 (same night, before any release). Both red-proofed.
552 lines
25 KiB
Go
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)
|
|
}
|
|
}
|