package stack import ( "fmt" "strings" "gopkg.in/yaml.v3" ) // ComposeSpec is a minimal, lenient representation of a compose file. // We only decode fields we need for validation + label-based proxy routing; // everything else is preserved as-is and passed to `docker compose`. type ComposeSpec struct { Version string `yaml:"version,omitempty"` Services map[string]ServiceSpec `yaml:"services"` } // ServiceSpec captures the subset of compose service fields we inspect. // // All host-escape-adjacent fields are decoded here even though Tinyforge // itself never reads them at runtime — surfacing them to Validate() is the // only way to *reject* them. Add new fields here when blocking a new // escape vector. type ServiceSpec struct { Image string `yaml:"image,omitempty"` Build any `yaml:"build,omitempty"` // banned — see Validate Ports []any `yaml:"ports,omitempty"` Labels map[string]string `yaml:"labels,omitempty"` Privileged bool `yaml:"privileged,omitempty"` Volumes []any `yaml:"volumes,omitempty"` NetworkMode string `yaml:"network_mode,omitempty"` Pid string `yaml:"pid,omitempty"` Ipc string `yaml:"ipc,omitempty"` UsernsMode string `yaml:"userns_mode,omitempty"` CapAdd []string `yaml:"cap_add,omitempty"` Devices []any `yaml:"devices,omitempty"` SecurityOpt []string `yaml:"security_opt,omitempty"` } // Parse decodes YAML into a ComposeSpec. Returns a descriptive error on failure. func Parse(yamlText string) (ComposeSpec, error) { var spec ComposeSpec if err := yaml.Unmarshal([]byte(yamlText), &spec); err != nil { return ComposeSpec{}, fmt.Errorf("invalid yaml: %w", err) } if len(spec.Services) == 0 { return ComposeSpec{}, fmt.Errorf("compose file has no services") } return spec, nil } // Validate enforces Tinyforge-level constraints beyond compose schema validity. // All blocked fields below are documented host-escape vectors: any one of // them on its own gives the container root on the host. Tinyforge already // owns the docker socket, so the threat model is "any admin == host root," // and these blocks raise the bar for any *future* viewer-to-admin // escalation as well as honest-mistake guardrails. // // Current rules: // - No service may set `privileged: true`. // - Every service must declare an image (build contexts disallowed). // - No host-IPC / host-PID / host-userns / host networking. // - No `cap_add`, `security_opt`, `devices`. // - `volumes` may not bind-mount the docker socket, /, /etc, /var, /proc, // /sys, /root, or /home — list is conservative; operators with real // bind-mount needs should ship a Source plugin or a dedicated wizard. func Validate(spec ComposeSpec) error { for name, svc := range spec.Services { if svc.Privileged { return fmt.Errorf("service %q: privileged mode is not allowed", name) } if svc.Image == "" { return fmt.Errorf("service %q: image is required (build contexts not supported)", name) } if svc.Build != nil { return fmt.Errorf("service %q: build: is not supported (use image:)", name) } if isBlockedNamespaceMode(svc.NetworkMode) { return fmt.Errorf("service %q: network_mode %q is not allowed", name, svc.NetworkMode) } if isBlockedNamespaceMode(svc.Pid) { return fmt.Errorf("service %q: pid: %q is not allowed", name, svc.Pid) } if isBlockedNamespaceMode(svc.Ipc) { return fmt.Errorf("service %q: ipc: %q is not allowed", name, svc.Ipc) } if isHostMode(svc.UsernsMode) { return fmt.Errorf("service %q: userns_mode %q is not allowed", name, svc.UsernsMode) } if len(svc.CapAdd) > 0 { return fmt.Errorf("service %q: cap_add is not allowed", name) } if len(svc.SecurityOpt) > 0 { return fmt.Errorf("service %q: security_opt is not allowed", name) } if len(svc.Devices) > 0 { return fmt.Errorf("service %q: devices is not allowed", name) } for _, v := range svc.Volumes { if host, ok := bindMountHostPath(v); ok { if isBlockedBindMount(host) { return fmt.Errorf("service %q: bind-mounting %q is not allowed", name, host) } } } } return nil } // isHostMode reports a host-namespace share, i.e. network_mode / pid / ipc / // userns_mode set to "host". (It deliberately does NOT match "host-gateway", // which is an extra_hosts value, not a namespace mode — matching it here only // produced misleading rejections.) func isHostMode(v string) bool { return v == "host" } // isBlockedNamespaceMode reports a namespace mode that must be rejected for // network_mode / pid / ipc: either host sharing ("host") or joining another // container's / compose service's namespace ("container:", // "service:"). The container/service joins are a lateral-movement and // sandbox-escape vector — a malicious service could attach to a victim // container's network or PID namespace. func isBlockedNamespaceMode(v string) bool { return isHostMode(v) || strings.HasPrefix(v, "container:") || strings.HasPrefix(v, "service:") } // bindMountHostPath extracts the host-side path from a compose volume // declaration. Compose accepts two shapes: a short string "src:dst[:mode]" // and a long form map with a "source" key. Returns ok=false for named // volumes (no host source). func bindMountHostPath(v any) (string, bool) { switch t := v.(type) { case string: // "named:/in/container" has no '/' or '.' prefix on the source. if t == "" { return "", false } parts := strings.SplitN(t, ":", 3) src := parts[0] if strings.HasPrefix(src, "/") || strings.HasPrefix(src, ".") || strings.HasPrefix(src, "~") { return src, true } return "", false case map[string]any: if typ, _ := t["type"].(string); typ != "" && typ != "bind" { return "", false } if src, ok := t["source"].(string); ok { if strings.HasPrefix(src, "/") || strings.HasPrefix(src, ".") || strings.HasPrefix(src, "~") { return src, true } } } return "", false } // isBlockedBindMount returns true for paths that obviously escape the // container's intended sandbox. Conservative deny-list — operators with // legitimate bind-mount needs should write a dedicated Source plugin // rather than tunnel them through compose. func isBlockedBindMount(host string) bool { // Normalize trailing slash so "/var" and "/var/" both match. clean := strings.TrimRight(host, "/") if clean == "" || clean == "/" { return true } // Relative ("./x", "../x", ".") and home-relative ("~/...") sources are // resolved by Docker against the compose working directory (which // Tinyforge controls and never intends as a host-bind source) or left // unexpanded — and "../" can climb out of that directory entirely. The // absolute-prefix deny-list below can't see these, so reject them // outright rather than give a false sense of coverage. if strings.HasPrefix(clean, ".") || strings.HasPrefix(clean, "~") { return true } // Specific blocked files / sockets. switch clean { case "/var/run/docker.sock", "/run/docker.sock": return true } // Blocked prefixes (cover sub-paths too). blocked := []string{"/etc", "/var", "/proc", "/sys", "/root", "/home", "/boot", "/dev"} for _, p := range blocked { if clean == p || strings.HasPrefix(clean, p+"/") { return true } } return false }