v0.328.0 (unreleased): the security review's four findings — the entry is bound to its image and start; symlinks out of a root; the missed classes; roots only under the drive namespace or system data
gates / gates (push) Successful in 42s

Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
2026-10-17 16:42:18 +02:00
parent 6d90374f63
commit 24d937d6e4
5 changed files with 202 additions and 12 deletions
@@ -1,11 +1,12 @@
{
"version": 1,
"note": "R-971 (operator decision D2 = A, 2026-10-17). The box's catalog allow-list, compiled into the controller. Each entry allows ONE exact setting for ONE service of ONE app; a changed setting is a different one and is refused. The catalog's scripts/privilege-allowlist.json is a byte-identical copy for its CI gate. Survey: felhom.eu documentation/audits/agent-allowlist-rotation-2026-10-17/P2/survey.md",
"note": "R-971 (operator decision D2 = A, 2026-10-17). The box's catalog allow-list, compiled into the controller. Each entry allows ONE exact setting for ONE service of ONE app, only while that service runs the entry's image repository with the image's own entrypoint and command; a changed setting is a different one and is refused. The catalog's scripts/privilege-allowlist.json is a byte-identical copy for its CI gate. Survey: felhom.eu documentation/audits/agent-allowlist-rotation-2026-10-17/P2/survey.md",
"entries": [
{
"app": "homepage",
"service": "homepage",
"setting": "bind /var/run/docker.sock -> /var/run/docker.sock ro",
"image": "ghcr.io/gethomepage/homepage",
"why": "the Docker widget and the docker service discovery list the household's containers and their state",
"doc": "https://gethomepage.dev/configs/docker/"
}
+109 -10
View File
@@ -54,8 +54,11 @@ type Entry struct {
App string `json:"app"`
Service string `json:"service"`
Setting string `json:"setting"`
Why string `json:"why"`
Doc string `json:"doc"`
// Image is the image REPOSITORY (no tag, no digest) the service must run for the entry to hold. Without it a push
// could keep the allowed setting and swap the image under it (security review 2026-10-17, finding 1).
Image string `json:"image"`
Why string `json:"why"`
Doc string `json:"doc"`
}
type allowList struct {
@@ -65,14 +68,17 @@ type allowList struct {
var allowed = mustLoad(allowListJSON)
func mustLoad(b []byte) map[string]bool {
func mustLoad(b []byte) map[string]Entry {
var l allowList
if err := json.Unmarshal(b, &l); err != nil {
panic("privpolicy: allowlist.json does not parse: " + err.Error())
}
m := map[string]bool{}
m := map[string]Entry{}
for _, e := range l.Entries {
m[key(e.App, e.Service, e.Setting)] = true
if e.Image == "" {
panic("privpolicy: allowlist.json entry " + e.App + "/" + e.Service + " names no image")
}
m[key(e.App, e.Service, e.Setting)] = e
}
return m
}
@@ -115,9 +121,17 @@ func (e *RefusedError) Error() string {
// Policy carries what the box knows about its own paths.
type Policy struct {
SystemDataPath string // paths.system_data_path — a data root may live under it (or under /mnt/)
SystemDataPath string // paths.system_data_path — a data root may live under it, or under /mnt/felhom-drives/
// Resolve, when set, resolves symlinks of an EXISTING bind source and of each root (filepath.EvalSymlinks on the
// box): a path inside a root that is a symlink out of it is outside (security review 2026-10-17, finding 2). nil =
// no filesystem (the catalog CI, a template with no app yet).
Resolve func(string) (string, error)
}
// DriveNamespace is where the box mounts every drive in the guest; a data root lives under it or under the system
// data path — never anywhere else under /mnt (finding 4: /mnt/<anything> was accepted).
const DriveNamespace = "/mnt/felhom-drives/"
// RootVars are the only variables a bind source may use.
var RootVars = []string{"HDD_PATH", "USERDATA_PATH", "IMPORT_PATH"}
@@ -141,10 +155,21 @@ func Admit(app string, compose []byte, env map[string]string, p Policy) error {
if err != nil {
return fmt.Errorf("the catalog definition of %s could not be checked: %w", app, err)
}
svcs := serviceFacts(compose, env)
var refused []Violation
for _, v := range vs {
if !allowed[key(app, v.Service, v.Setting)] {
e, ok := allowed[key(app, v.Service, v.Setting)]
if !ok {
refused = append(refused, v)
continue
}
// Finding 1: the entry holds only for the image it names, and only while the service runs that image's own
// start — a changed entrypoint or command would run anything with the allowed right.
f := svcs[v.Service]
if f.repo != e.Image {
refused = append(refused, Violation{Service: v.Service, Setting: v.Setting + " with image " + f.repo + " (allowed only for " + e.Image + ")"})
} else if f.startOverride {
refused = append(refused, Violation{Service: v.Service, Setting: v.Setting + " with a changed entrypoint or command"})
}
}
if len(refused) > 0 {
@@ -153,6 +178,39 @@ func Admit(app string, compose []byte, env map[string]string, p Policy) error {
return nil
}
type facts struct {
repo string // the rendered image's repository (no tag, no digest)
startOverride bool // entrypoint or command set
}
// serviceFacts reads, per service, what an allow-list entry is bound to.
func serviceFacts(compose []byte, env map[string]string) map[string]facts {
var doc map[string]any
_ = yaml.Unmarshal(compose, &doc)
c := &checker{env: env}
out := map[string]facts{}
svcs, _ := doc["services"].(map[string]any)
for name, v := range svcs {
sd, _ := v.(map[string]any)
_, ep := sd["entrypoint"]
_, cmd := sd["command"]
out[name] = facts{repo: imageRepo(c.render(sd["image"])), startOverride: ep || cmd}
}
return out
}
// imageRepo is an image reference without its digest and tag ("ghcr.io/x/y:1.2@sha256:…" → "ghcr.io/x/y").
func imageRepo(ref string) string {
ref = strings.TrimSpace(ref)
if i := strings.Index(ref, "@"); i >= 0 {
ref = ref[:i]
}
if i := strings.LastIndex(ref, ":"); i > strings.LastIndex(ref, "/") {
ref = ref[:i]
}
return ref
}
// Check lists every setting in a class, allowed or not, sorted.
func Check(compose []byte, env map[string]string, p Policy) ([]Violation, error) {
var doc map[string]any
@@ -211,11 +269,13 @@ func (c *checker) add(svc, setting string) {
}
// scalarKeys are the classes whose whole value is the setting.
var scalarKeys = []string{"network_mode", "pid", "ipc", "uts", "userns_mode", "cgroup"}
var scalarKeys = []string{"network_mode", "pid", "ipc", "uts", "userns_mode", "cgroup", "cgroup_parent", "runtime",
"isolation"}
// privilegedKeys are every key whose value must be literal (no ${VAR}).
var privilegedKeys = []string{"privileged", "cap_add", "devices", "device_cgroup_rules", "network_mode", "pid", "ipc",
"uts", "userns_mode", "cgroup", "security_opt", "sysctls", "volumes_from"}
"uts", "userns_mode", "cgroup", "cgroup_parent", "runtime", "isolation", "security_opt", "sysctls", "volumes_from",
"gpus", "provider", "post_start", "pre_stop"}
func (c *checker) service(name string, sd map[string]any) {
c.svc = name
@@ -269,6 +329,28 @@ func (c *checker) service(name string, sd map[string]any) {
if _, has := sd["build"]; has {
c.add(name, "build")
}
// Finding 3 (security review 2026-10-17): device requests outside `devices:`, a provider service (compose runs a
// plugin binary ON THE HOST for it), and lifecycle hooks (they can run a command privileged).
if v, has := sd["gpus"]; has {
c.add(name, "gpus "+c.canon(v))
}
if dep, ok := sd["deploy"].(map[string]any); ok {
if res, ok := dep["resources"].(map[string]any); ok {
if rv, ok := res["reservations"].(map[string]any); ok {
if d, has := rv["devices"]; has {
c.add(name, "device_request "+c.canon(d))
}
}
}
}
if v, has := sd["provider"]; has {
c.add(name, "provider "+c.canon(v))
}
for _, hook := range []string{"post_start", "pre_stop"} {
if v, has := sd[hook]; has {
c.add(name, hook+" "+c.canon(v))
}
}
if ex, ok := sd["extends"].(map[string]any); ok {
if f, has := ex["file"]; has {
c.add(name, "extends_file "+c.shown(f))
@@ -396,7 +478,7 @@ func (c *checker) validRoots() []string {
if v == "" || !strings.HasPrefix(v, "/") || filepath.Clean(v) != v || v == "/" || hasDotDot(v) {
continue
}
if strings.HasPrefix(v, "/mnt/") || (c.p.SystemDataPath != "" && within(v, filepath.Clean(c.p.SystemDataPath))) {
if strings.HasPrefix(v, DriveNamespace) || (c.p.SystemDataPath != "" && within(v, filepath.Clean(c.p.SystemDataPath))) {
out = append(out, v)
}
}
@@ -408,8 +490,25 @@ func (c *checker) underRoot(p string) bool {
return false
}
p = filepath.Clean(p)
in := false
for _, r := range c.roots {
if within(p, r) {
in = true
break
}
}
if !in || c.p.Resolve == nil {
return in
}
// On the box: an existing source is judged by where it really points. A source that does not exist yet (the
// deploy belt creates it as a directory) is judged by its name; a root that cannot be resolved is no root.
rp, err := c.p.Resolve(p)
if err != nil {
return true
}
for _, r := range c.roots {
rr, rerr := c.p.Resolve(r)
if rerr == nil && within(rp, rr) {
return true
}
}
@@ -267,3 +267,84 @@ func TestAllowList_CatalogCopyIsIdentical(t *testing.T) {
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) {
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")
}
}
+2 -1
View File
@@ -53,7 +53,8 @@ func (m *Manager) admitCompose(name string, data []byte, env map[string]string)
if env != nil {
e = m.policyEnv(env)
}
err := privpolicy.Admit(name, data, e, privpolicy.Policy{SystemDataPath: m.sysDataPath})
// Resolve: a bind source that is a symlink out of its data root is outside (it is judged where it points).
err := privpolicy.Admit(name, data, e, privpolicy.Policy{SystemDataPath: m.sysDataPath, Resolve: filepath.EvalSymlinks})
m.noteAdmission(name, data, err)
return err
}