// Package privpolicy is the box-side catalog allow-list (R-971, operator decision D2 = A, 2026-10-17; // felhom.eu `02-controller-module-map.md` „The catalog allow-list"). // // A push to the catalog's `main` reaches every installed app within about 15 minutes. Before this package nothing on // the box refused a dangerous container setting, so whoever could push to the catalog could take every guest. Now // every path that writes an app's compose asks Admit first, on the RENDERED compose (after variable substitution), // and writes nothing when it refuses. A setting in one of the classes below is refused unless THIS app's service has // exactly that setting on the allow-list compiled into the controller (allowlist.json, go:embed) — a change in Gitea // cannot add to it. An allowed setting that changes (a read-only socket becoming read-write) is a different setting, // so it is refused too. // // The classes (each a Violation.Setting prefix): // // privileged, cap_add, device, device_cgroup_rule, network_mode, pid, ipc, uts, userns_mode, cgroup, // security_opt (every value except no-new-privileges), sysctl, volumes_from, // bind (a host path outside the box's data roots, the Docker socket anywhere, mount propagation), // volume (a named volume with a driver, driver_opts, external or an explicit name — each can alias a host path or // another stack's volume), config_file / secret_file / env_file (a host file outside the stack directory), // build, include, extends_file, // interpolated (a ${VAR} inside any of the keys above — a value the catalog's own deploy fields could later change) // // The data roots are the values the controller itself gives the app: HDD_PATH, USERDATA_PATH and IMPORT_PATH, each // an absolute clean path under /mnt/ or under the system data path. A bind source may use only those three variables, // and only as its leading token. // // The controller's own infra stacks (traefik, cloudflared, filebrowser, samba) and its override files (setup gate, // family gate, install hold, media library) are rendered by the controller, not the catalog: out of scope. // // Pinned by privpolicy_test.go (one refused case per class, a positive control per class, the allow-list's exact- // match rule) and stacks.TestAllowList_IsWiredIntoEveryComposeWriter (the structural guard). package privpolicy import ( _ "embed" "encoding/json" "fmt" "path/filepath" "regexp" "sort" "strings" "gopkg.in/yaml.v3" ) //go:embed allowlist.json var allowListJSON []byte // AllowListJSON is the compiled-in allow-list, byte for byte (the catalog keeps an identical copy for its CI gate; // TestAllowList_CatalogCopyIsIdentical pins the two). func AllowListJSON() []byte { return append([]byte(nil), allowListJSON...) } // Entry is one allowed setting. type Entry struct { App string `json:"app"` Service string `json:"service"` Setting string `json:"setting"` // 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 { Version int `json:"version"` Entries []Entry `json:"entries"` } var allowed = mustLoad(allowListJSON) 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]Entry{} for _, e := range l.Entries { 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 } func key(app, svc, setting string) string { return app + "\x00" + svc + "\x00" + setting } // Entries returns the allow-list (for the doc and the app page). func Entries() []Entry { var l allowList _ = json.Unmarshal(allowListJSON, &l) return l.Entries } // Violation is one setting in a class. Service is "" for a top-level definition (a volume, include). type Violation struct { Service string Setting string } func (v Violation) String() string { if v.Service == "" { return v.Setting } return v.Service + ": " + v.Setting } // RefusedError lists what was refused, in plain words. The app keeps its last good compose. type RefusedError struct { App string Violations []Violation } func (e *RefusedError) Error() string { parts := make([]string, len(e.Violations)) for i, v := range e.Violations { parts[i] = v.String() } return fmt.Sprintf("the catalog definition of %s asks for container rights the box does not allow: %s", e.App, strings.Join(parts, "; ")) } // 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/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/ 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"} // PlaceholderEnv is the env a template is checked with when no app env exists yet (the sync of an app that is not // installed, the catalog CI). Same shape as the controller gives; the catalog gate uses the same three values. func PlaceholderEnv() map[string]string { return map[string]string{ "HDD_PATH": "/mnt/felhom-drives/check/felhom-data", "USERDATA_PATH": "/mnt/felhom-drives/check/felhom-data/userdata", "IMPORT_PATH": "/mnt/felhom-drives/check/felhom-data/userdata/import", "DOMAIN": "example.invalid", "SUBDOMAIN": "check", } } // Admit is the one question every compose writer asks. nil = write it; *RefusedError = write nothing; any other // error = the compose could not be read, which refuses too (fail closed: a definition the check cannot read is not a // definition it has checked). func Admit(app string, compose []byte, env map[string]string, p Policy) error { vs, err := Check(compose, env, p) 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 { 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 { return &RefusedError{App: app, Violations: refused} } 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 if err := yaml.Unmarshal(compose, &doc); err != nil { return nil, err } c := &checker{env: env, p: p} c.roots = c.validRoots() if _, ok := doc["include"]; ok { c.add("", "include") } if vols, ok := doc["volumes"].(map[string]any); ok { names := sortedKeys(vols) for _, n := range names { def, _ := vols[n].(map[string]any) for _, k := range []string{"driver", "driver_opts", "external", "name"} { if v, has := def[k]; has && !(k == "driver" && c.render(v) == "local") { c.add("", fmt.Sprintf("volume %s %s %s", n, k, c.canon(v))) } } } } for _, top := range []string{"configs", "secrets"} { defs, _ := doc[top].(map[string]any) for _, n := range sortedKeys(defs) { def, _ := defs[n].(map[string]any) if f, has := def["file"]; has && !c.insideStack(c.render(f)) { c.add("", fmt.Sprintf("%s_file %s %s", strings.TrimSuffix(top, "s"), n, c.shown(f))) } } } svcs, _ := doc["services"].(map[string]any) for _, name := range sortedKeys(svcs) { sd, _ := svcs[name].(map[string]any) c.service(name, sd) } sort.Slice(c.out, func(i, j int) bool { if c.out[i].Service != c.out[j].Service { return c.out[i].Service < c.out[j].Service } return c.out[i].Setting < c.out[j].Setting }) return c.out, nil } type checker struct { env map[string]string p Policy roots []string out []Violation svc string } func (c *checker) add(svc, setting string) { c.out = append(c.out, Violation{Service: svc, Setting: setting}) } // scalarKeys are the classes whose whole value is the setting. 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", "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 // A ${VAR} in a privileged key is refused as such, and the key is NOT rendered: its value could be a secret, and a // refusal names what it refused (logs, the operator event). interp := map[string]bool{} for _, k := range privilegedKeys { if v, has := sd[k]; has && hasInterp(v) { c.add(name, "interpolated "+k) interp[k] = true } } sd = withoutKeys(sd, interp) if v, has := sd["privileged"]; has && truthy(c.render(v)) { c.add(name, "privileged true") } for _, v := range list(sd["cap_add"]) { c.add(name, "cap_add "+strings.TrimPrefix(strings.ToUpper(c.render(v)), "CAP_")) } for _, v := range list(sd["devices"]) { c.add(name, "device "+c.canon(v)) } for _, v := range list(sd["device_cgroup_rules"]) { c.add(name, "device_cgroup_rule "+c.render(v)) } for _, k := range scalarKeys { if v, has := sd[k]; has { c.add(name, k+" "+c.render(v)) } } for _, v := range list(sd["security_opt"]) { s := c.render(v) if noNewPrivileges(s) { continue // a hardening, not a privilege } c.add(name, "security_opt "+s) } switch sy := sd["sysctls"].(type) { case map[string]any: for _, k := range sortedKeys(sy) { c.add(name, fmt.Sprintf("sysctl %s=%s", k, c.render(sy[k]))) } case []any: for _, v := range sy { c.add(name, "sysctl "+c.render(v)) } } for _, v := range list(sd["volumes_from"]) { c.add(name, "volumes_from "+c.render(v)) } 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)) } } for _, v := range list(sd["env_file"]) { raw := v if m, ok := v.(map[string]any); ok { raw = m["path"] } if !c.insideStack(c.render(raw)) { c.add(name, "env_file "+c.shown(raw)) } } for _, v := range list(sd["volumes"]) { c.volume(name, v) } } // volume classifies one service volume entry (short or long syntax). // withoutKeys returns sd without the named keys (a shallow copy; sd itself is not changed). func withoutKeys(sd map[string]any, drop map[string]bool) map[string]any { if len(drop) == 0 { return sd } out := make(map[string]any, len(sd)) for k, v := range sd { if !drop[k] { out[k] = v } } return out } func (c *checker) volume(svc string, v any) { var rawSrc, src, dst, mode, prop string switch t := v.(type) { case string: parts := strings.Split(t, ":") if len(parts) < 2 { return // an anonymous volume: a container path only } rawSrc = parts[0] src, dst = c.render(parts[0]), c.render(parts[1]) mode = "rw" if len(parts) > 2 { for _, o := range strings.Split(parts[2], ",") { switch o { case "ro": mode = "ro" case "shared", "rshared", "slave", "rslave": prop = o } } } if !isPathSource(src) && !isPathSource(rawSrc) { return // a named volume (its definition is checked at the top level) } case map[string]any: typ := c.render(t["type"]) rawSrc = fmt.Sprint(t["source"]) src, dst = c.render(t["source"]), c.render(t["target"]) mode = "rw" if truthy(c.render(t["read_only"])) { mode = "ro" } if b, ok := t["bind"].(map[string]any); ok { if p := c.render(b["propagation"]); p == "shared" || p == "rshared" || p == "slave" || p == "rslave" { prop = p } } switch typ { case "volume", "tmpfs", "image": return case "bind": default: c.add(svc, "volume type "+typ) return } default: return } shown := src if !rootLed(rawSrc) { shown = rawSrc // the template text: a rendered non-root variable could be a secret } setting := fmt.Sprintf("bind %s -> %s %s", shown, dst, mode) if prop != "" { setting += " " + prop } if !rootLed(rawSrc) || prop != "" || isSocket(src) || !c.underRoot(src) { c.add(svc, setting) } } // rootLed reports whether a TEMPLATE bind source uses no variable, or exactly one root variable as its leading token. func rootLed(raw string) bool { if !strings.Contains(strings.ReplaceAll(raw, "$$", ""), "$") { return true // a literal path (judged by underRoot) } for _, r := range RootVars { for _, form := range []string{"${" + r + "}", "$" + r} { if rest, ok := strings.CutPrefix(raw, form); ok && (rest == "" || strings.HasPrefix(rest, "/")) && !strings.Contains(strings.ReplaceAll(rest, "$$", ""), "$") { return true } } } return false } func isPathSource(s string) bool { return strings.HasPrefix(s, "/") || strings.HasPrefix(s, ".") || strings.HasPrefix(s, "~") || strings.HasPrefix(s, "$") } func isSocket(p string) bool { return strings.HasSuffix(p, ".sock") || strings.Contains(p, "docker.sock") } // validRoots are the root values that are absolute, clean, not "/" and under /mnt/ or the system data path. func (c *checker) validRoots() []string { var out []string for _, r := range RootVars { v := c.env[r] if v == "" || !strings.HasPrefix(v, "/") || filepath.Clean(v) != v || v == "/" || hasDotDot(v) { continue } if strings.HasPrefix(v, DriveNamespace) || (c.p.SystemDataPath != "" && within(v, filepath.Clean(c.p.SystemDataPath))) { out = append(out, v) } } return out } func (c *checker) underRoot(p string) bool { if !strings.HasPrefix(p, "/") || hasDotDot(p) { 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 } } return false } // insideStack: a relative path that stays inside the stack directory. func (c *checker) insideStack(p string) bool { return p != "" && !strings.HasPrefix(p, "/") && !strings.HasPrefix(p, "~") && !hasDotDot(p) } func within(p, root string) bool { return p == root || strings.HasPrefix(p, root+"/") } func hasDotDot(p string) bool { for _, s := range strings.Split(p, "/") { if s == ".." { return true } } return false } func noNewPrivileges(s string) bool { s = strings.ReplaceAll(strings.ReplaceAll(s, "=", ":"), " ", "") return s == "no-new-privileges" || s == "no-new-privileges:true" } func truthy(s string) bool { switch strings.ToLower(strings.TrimSpace(s)) { case "true", "yes", "on", "1": return true } return false } func list(v any) []any { switch t := v.(type) { case []any: return t case nil: return nil default: return []any{t} } } func sortedKeys(m map[string]any) []string { ks := make([]string, 0, len(m)) for k := range m { ks = append(ks, k) } sort.Strings(ks) return ks } // canon renders a scalar, or a structure as compact JSON with rendered strings (stable key order). func (c *checker) canon(v any) string { switch t := v.(type) { case string: return c.render(t) case map[string]any, []any: b, _ := json.Marshal(c.renderTree(t)) return string(b) default: return fmt.Sprint(t) } } func (c *checker) renderTree(v any) any { switch t := v.(type) { case string: return c.render(t) case map[string]any: m := map[string]any{} for k, x := range t { m[k] = c.renderTree(x) } return m case []any: out := make([]any, len(t)) for i, x := range t { out[i] = c.renderTree(x) } return out default: return t } } // shown is a value as a refusal may print it: rendered when it holds no variable, the template text when it does. func (c *checker) shown(v any) string { if hasInterp(v) { return fmt.Sprint(v) } return c.render(v) } func hasInterp(v any) bool { switch t := v.(type) { case string: return strings.Contains(strings.ReplaceAll(t, "$$", ""), "$") case map[string]any: for k, x := range t { if hasInterp(k) || hasInterp(x) { return true } } case []any: for _, x := range t { if hasInterp(x) { return true } } } return false } var interpRe = regexp.MustCompile(`\$\$|\$\{([A-Za-z_][A-Za-z0-9_]*)(?:(:?[-?+])([^}]*))?\}|\$([A-Za-z_][A-Za-z0-9_]*)`) // render substitutes compose's variable forms ($$, ${V}, ${V:-d}, ${V-d}, ${V:?e}, ${V?e}, ${V:+a}, ${V+a}, $V) from // the env. A non-string scalar is printed as YAML would read it. func (c *checker) render(v any) string { s, ok := v.(string) if !ok { if v == nil { return "" } return fmt.Sprint(v) } return interpRe.ReplaceAllStringFunc(s, func(m string) string { if m == "$$" { return "$" } g := interpRe.FindStringSubmatch(m) name, op, arg := g[1], g[2], g[3] if name == "" { name = g[4] } val, set := c.env[name] switch op { case ":-": if val == "" { return arg } case "-": if !set { return arg } case ":+": if val != "" { return arg } return "" case "+": if set { return arg } return "" } return val }) } // controllerInfra are the stacks the controller renders itself (out of this check's scope — and never a catalog // template's to overwrite). var controllerInfra = map[string]bool{"traefik": true, "cloudflared": true, "filebrowser": true, "samba": true, "felhom-controller": true} // IsControllerInfra reports whether name is one of the controller's own infra stacks. func IsControllerInfra(name string) bool { return controllerInfra[strings.ToLower(name)] }