v0.328.0 (unreleased): security review round 2 — nearest-ancestor symlink resolve (fail closed), any extends refused, the entry binds a literal image and its exact healthcheck, no variable in a bind's options
gates / gates (push) Successful in 44s
gates / gates (push) Successful in 44s
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
@@ -182,6 +182,8 @@ const homepage = `services:
|
||||
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:
|
||||
`
|
||||
@@ -348,3 +350,59 @@ func TestAdmit_ARootElsewhereUnderMntIsNoRoot(t *testing.T) {
|
||||
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) {
|
||||
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)")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user