03ab121b25
gates / gates (push) Successful in 46s
Security review of 2fe6341 (same night, before any release). Both red-proofed.
786 lines
26 KiB
Go
786 lines
26 KiB
Go
// 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. Under HDD_PATH the root is the app's OWN folder, ${HDD_PATH}/appdata/<folder>/ (R-1142):
|
|
// <folder> is the app's name, or its entry in the list's appdata_folders (paperless-ngx keeps appdata/paperless) — so a
|
|
// catalog push cannot hand one app another app's files.
|
|
//
|
|
// 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"
|
|
"errors"
|
|
"fmt"
|
|
"io/fs"
|
|
"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"`
|
|
// Healthcheck is the exact `healthcheck.test` (compact JSON) the service may carry — the test runs a command inside
|
|
// the container holding the allowed right, so a changed one is a changed start (round 2). "" = no healthcheck.
|
|
Healthcheck string `json:"healthcheck"`
|
|
Why string `json:"why"`
|
|
Doc string `json:"doc"`
|
|
}
|
|
|
|
type allowList struct {
|
|
Version int `json:"version"`
|
|
Entries []Entry `json:"entries"`
|
|
// AppdataFolders names an app's folder under ${HDD_PATH}/appdata/ when it is not the app's own name (R-1142). It is
|
|
// compiled in with the entries for the same reason: the catalog — the threat — must not be able to name the folder.
|
|
AppdataFolders map[string]string `json:"appdata_folders"`
|
|
}
|
|
|
|
var allowed, appdataFolders = mustLoad(allowListJSON)
|
|
|
|
func mustLoad(b []byte) (map[string]Entry, map[string]string) {
|
|
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
|
|
}
|
|
owner := map[string]string{}
|
|
for app, f := range l.AppdataFolders {
|
|
if !oneSegment(f) {
|
|
panic("privpolicy: allowlist.json appdata folder of " + app + " is not one folder name: " + f)
|
|
}
|
|
if o, dup := owner[f]; dup {
|
|
panic("privpolicy: allowlist.json gives the appdata folder " + f + " to both " + o + " and " + app)
|
|
}
|
|
owner[f] = app
|
|
}
|
|
return m, l.AppdataFolders
|
|
}
|
|
|
|
// oneSegment: a single folder name — no separator, not "." or "..", not empty.
|
|
func oneSegment(s string) bool {
|
|
return s != "" && s != "." && s != ".." && !strings.ContainsAny(s, "/\\$")
|
|
}
|
|
|
|
// AppdataFolder is the app's own folder under ${HDD_PATH}/appdata/ (R-1142): its appdata_folders entry, else its name.
|
|
// "" when the app has no usable name — then HDD_PATH gives it no root at all (fail closed).
|
|
func AppdataFolder(app string) string {
|
|
if f, ok := appdataFolders[app]; ok {
|
|
return f
|
|
}
|
|
if !oneSegment(app) {
|
|
return ""
|
|
}
|
|
// A folder the map gives to ANOTHER app is not this app's, even when this app is named like it: a catalog push
|
|
// adding a template called "paperless" must not get paperless-ngx's files (security review 2026-10-18).
|
|
for _, f := range appdataFolders {
|
|
if f == app {
|
|
return ""
|
|
}
|
|
}
|
|
return app
|
|
}
|
|
|
|
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/<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"}
|
|
|
|
// 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(app, 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]
|
|
switch {
|
|
case f.imageInterp:
|
|
refused = append(refused, Violation{Service: v.Service, Setting: v.Setting + " with a variable in its image"})
|
|
case f.repo != e.Image:
|
|
refused = append(refused, Violation{Service: v.Service, Setting: v.Setting + " with image " + f.repo + " (allowed only for " + e.Image + ")"})
|
|
case f.startOverride:
|
|
refused = append(refused, Violation{Service: v.Service, Setting: v.Setting + " with a changed entrypoint or command"})
|
|
case f.healthcheck != e.Healthcheck:
|
|
refused = append(refused, Violation{Service: v.Service, Setting: v.Setting + " with a changed healthcheck"})
|
|
}
|
|
}
|
|
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)
|
|
imageInterp bool // the image reference holds a ${VAR}: a deploy-field default could change it later
|
|
startOverride bool // entrypoint or command set
|
|
healthcheck string // compact JSON of healthcheck.test, "" when none
|
|
}
|
|
|
|
// 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"]
|
|
hc := ""
|
|
if h, ok := sd["healthcheck"].(map[string]any); ok {
|
|
if t, has := h["test"]; has {
|
|
b, _ := json.Marshal(t)
|
|
hc = string(b)
|
|
}
|
|
}
|
|
out[name] = facts{repo: imageRepo(c.render(sd["image"])), imageInterp: hasInterp(sd["image"]), startOverride: ep || cmd, healthcheck: hc}
|
|
}
|
|
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. app decides the HDD_PATH root (R-1142).
|
|
func Check(app string, 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, app: app}
|
|
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 {
|
|
app string
|
|
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))
|
|
}
|
|
}
|
|
// Any `extends` (round 2): a second service extending an allowed one INHERITS its rights — its socket bind would
|
|
// reach a service and an image nobody allowed, and the raw file this check reads would not show it.
|
|
switch ex := sd["extends"].(type) {
|
|
case map[string]any:
|
|
if f, has := ex["file"]; has {
|
|
c.add(name, "extends_file "+c.shown(f))
|
|
} else {
|
|
c.add(name, "extends "+c.shown(ex["service"]))
|
|
}
|
|
case string:
|
|
c.add(name, "extends "+c.shown(ex))
|
|
}
|
|
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]
|
|
if isPathSource(rawSrc) && ((len(parts) > 1 && !rootLed(parts[1])) || (len(parts) > 2 && hasInterp(strings.Join(parts[2:], ":")))) {
|
|
// round 2: a ${VAR} in the options (or a non-root one in the target) could turn on propagation later — the
|
|
// options are literal, the target may use a root variable only (the *arr apps mount ${USERDATA_PATH}/media
|
|
// at the same path inside).
|
|
c.add(svc, "bind "+t+" with a variable in its target or options")
|
|
return
|
|
}
|
|
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))) {
|
|
if r == "HDD_PATH" {
|
|
// round 3: HDD_PATH is the drive's whole namespace — its backups/ (recovery units carry app secrets)
|
|
// and every other top-level folder are siblings an app has no business binding. Only appdata/ is a
|
|
// root; the household's folders come through USERDATA_PATH.
|
|
// R-1142: and only the app's OWN folder in it — appdata/<other app>/ is another app's files.
|
|
f := AppdataFolder(c.app)
|
|
if f == "" {
|
|
continue
|
|
}
|
|
v = filepath.Join(v, "appdata", f)
|
|
}
|
|
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: a source is judged by where it really points. A path that does not exist yet (the deploy belt — or
|
|
// Docker — creates it) is judged through its nearest EXISTING ancestor, so a symlink higher up cannot carry a
|
|
// not-yet-created directory out of the root (security review 2026-10-17, round 2). Any resolve error other than
|
|
// "does not exist" refuses (fail closed).
|
|
rp, ok := c.resolveExisting(p)
|
|
if !ok {
|
|
return false
|
|
}
|
|
for _, r := range c.roots {
|
|
// A root is resolved the same way (nearest existing ancestor): a drive folder not created yet must not make
|
|
// every bind under it "outside" (found live on 9201 with rc.4: every not-installed app refused).
|
|
if rr, ok := c.resolveExisting(r); ok && within(rp, rr) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// resolveExisting resolves the nearest existing ancestor of p and re-joins the rest.
|
|
func (c *checker) resolveExisting(p string) (string, bool) {
|
|
rest := ""
|
|
for q := p; ; q = filepath.Dir(q) {
|
|
r, err := c.p.Resolve(q)
|
|
if err == nil {
|
|
return filepath.Join(r, rest), true
|
|
}
|
|
if !errors.Is(err, fs.ErrNotExist) {
|
|
return "", false
|
|
}
|
|
if q == "/" || q == "." {
|
|
return "", false
|
|
}
|
|
rest = filepath.Join(filepath.Base(q), rest)
|
|
}
|
|
}
|
|
|
|
// 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)] }
|