Keep places and accesses at the terminal, and refuse a line break in any setting
mesh/delivery delivered
mesh/merge-gate pass: builds build-agent, mesh-controller, route-proxy → ace, g14, novox, shanks; no bus step; every machine composes with the change as it…
mesh/repo-check pass: its merge-check.sh passed

Through the settings verb, or a settings line run by the generic command
verb, any caller of the mesh's console could place a module's directory at
/etc with an owner of its own and have the node-engine, as root, hand it
over at the next push, or mount any of the machine's paths into a container
(hq issue 339). A change to either key is now refused in every process a
verb runs, the generic verb refuses settings writes outright, and neither key
may name the machine's own trees from anywhere, the terminal included. A
line break, carriage return or NUL in any setting, which a file it is
written into reads as a line of the caller's own, is refused where a layer
is kept and where it is composed; PEM blocks alone may hold lines.
This commit is contained in:
jochen
2026-10-08 23:58:08 +02:00
parent d059311c0f
commit f5824b31c6
8 changed files with 452 additions and 4 deletions
+1 -1
View File
@@ -19,7 +19,7 @@ import (
// A root certificate, in the shape a certificate authority serves one.
const servedRoot = `-----BEGIN CERTIFICATE-----
MIIBeDCCAR2gAwIBAgIQfake0000000000000000000000
MIIBeDCCAR2gAwIBAgIQfake000000000000000000000000
-----END CERTIFICATE-----
`
+50 -2
View File
@@ -2,6 +2,7 @@ package catalogue
import (
"fmt"
"path/filepath"
"regexp"
"sort"
"strings"
@@ -44,6 +45,43 @@ type Placement struct {
var ownerShape = regexp.MustCompile(`^[0-9]+:[0-9]+$`)
// Where no placement and no access may be, from any route, the controller's terminal too (novox/hq issue 339).
//
// A placed directory is created and owned by the node-engine as root, with the owner the setting names, and
// whatever the module writes into it is written as root; an access is mounted into the module's container,
// which may run as root. A place at /etc owned by an account a caller names hands that account the machine,
// and an access at / mounts the machine's root into a container. So the machine's own trees are refused here,
// before anything is kept or composed, and the node-engine refuses them again where it applies.
//
// systemTrees are refused at and below: the machine's system, the kernel's, the boot loader's, root's home,
// what lives only while the machine runs, and the node-engine's and the mesh's own state. systemRoots are
// refused at, and wherever a path holds one (an ancestor of /var/lib holds it): each is the parent of every
// module's or every person's directories, and owning it is owning all of them.
var (
systemTrees = []string{"/etc", "/usr", "/boot", "/root", "/run", "/var/run", "/var/lock", "/proc", "/sys",
"/dev", "/bin", "/sbin", "/lib", "/lib32", "/lib64", "/var/lib/mesh", "/var/lib/mesh-host"}
systemRoots = []string{"/", "/var", "/var/lib", "/var/cache", "/var/log", "/var/tmp", "/var/spool", "/home",
"/mnt", "/media", "/srv", "/opt", "/tmp", "/storage", "/data", "/services"}
)
// systemPath says why a clean absolute path is the machine's own and never a placement's or an access's, or "".
func systemPath(path string) string {
for _, tree := range systemTrees {
if path == tree || strings.HasPrefix(path, tree+"/") {
return path + " is in " + tree + ", the machine's own or the mesh's state"
}
if strings.HasPrefix(tree, path+"/") || path == "/" {
return path + " holds " + tree + ", the machine's own or the mesh's state"
}
}
for _, root := range systemRoots {
if path == root {
return path + " is the parent of every module's or every person's directories"
}
}
return ""
}
// accessRef is how a module names one of its accesses: ${access:<id>}.
var accessRef = regexp.MustCompile(`\$\{access:([a-z0-9][a-z0-9-]*)\}`)
@@ -103,7 +141,12 @@ func Places(m Manifest, layers []Layer) (map[string]Placement, error) {
if !strings.HasPrefix(p.Path, "/") {
return nil, fmt.Errorf("%s places %q at %q, which is not an absolute path", m.Module, id, p.Path)
}
p.Path = strings.TrimRight(p.Path, "/")
p.Path = filepath.Clean(p.Path)
if why := systemPath(p.Path); why != "" {
return nil, fmt.Errorf("%s places %q at %s: %s, and the node-engine creates and owns a placed "+
"directory as root, with the owner the setting names — no placement is ever there "+
"(novox/hq issue 339)", m.Module, id, p.Path, why)
}
out[id] = p
}
}
@@ -147,7 +190,12 @@ func AccessPlaces(m Manifest, layers []Layer) (map[string]string, error) {
return nil, fmt.Errorf("%s places the access %q at %v, which is not an absolute path",
m.Module, id, body)
}
out[id] = strings.TrimRight(path, "/")
path = filepath.Clean(path)
if why := systemPath(path); why != "" {
return nil, fmt.Errorf("%s places the access %q at %s: %s, and an access is mounted into the "+
"module's container — no access is ever there (novox/hq issue 339)", m.Module, id, path, why)
}
out[id] = path
}
}
if len(out) == 0 {
+110
View File
@@ -209,6 +209,113 @@ func deepCopy(in map[string]any) map[string]any {
return out
}
// settingsHoldOneLine refuses a line break, a carriage return or a NUL in any string of any setting, for every
// module, at any depth, keys as well as values, in an object or a list (novox/hq issue 339). A value is
// substituted into env and configuration files the node-engine writes as root — an app's env file, a logind
// drop-in — and a line break there is a line of the caller's own: a directive, an assignment, a section. A NUL
// ends a string early wherever C reads it. Judged where a layer is kept and again where it is composed, so a
// layer that holds one, however it got into the store, is said with its key. What must hold lines is a file
// of the module's own, never a setting.
func settingsHoldOneLine(module string, layers []Layer) error {
for _, layer := range layers {
for _, key := range sortedKeysAny(layer.Values) {
if at := lineBreakIn(layer.Values[key], key); at != "" {
return fmt.Errorf("%s: the setting %s in %q holds a line break, a carriage return or a NUL, which a "+
"file it is written into would read as a line of its own; a setting is one line (novox/hq "+
"issue 339)", module, at, layer.From)
}
}
}
return nil
}
// pemBlocks says whether a value is PEM blocks and nothing else: the one value with lines a setting may hold,
// because a provider serves its certificate authority's root to its consumers as one (step-ca to the route
// proxy). Each block is a BEGIN line, base64 lines of exactly 64 characters but the last, and the END line of
// the same label; the last line is a whole number of base64 groups, padded at its end alone. So no line of it
// is a path, an option, a section or an assignment of a name a program reads — a line break with anything
// else around it is refused.
func pemBlocks(v string) bool {
lines := strings.Split(strings.TrimSuffix(v, "\n"), "\n")
if len(lines) < 3 {
return false
}
for i := 0; i < len(lines); {
label, ok := strings.CutPrefix(lines[i], "-----BEGIN ")
if !ok || !strings.HasSuffix(label, "-----") {
return false
}
label = strings.TrimSuffix(label, "-----")
if label == "" || strings.Trim(label, "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 ") != "" {
return false
}
i++
body := 0
for i < len(lines) && !strings.HasPrefix(lines[i], "-----END ") {
body++
i++
}
if body == 0 || i == len(lines) || lines[i] != "-----END "+label+"-----" {
return false
}
for j, line := range lines[i-body : i] {
if !base64Line(line, j == body-1) {
return false
}
}
i++
}
return true
}
// base64Line is one line of a PEM body: 64 characters of the base64 alphabet, or for the last line at most 64,
// a whole number of groups, with at most two '=' at its end.
func base64Line(line string, last bool) bool {
if line == "" || len(line) > 64 || (!last && len(line) != 64) || len(line)%4 != 0 {
return false
}
data := strings.TrimRight(line, "=")
if len(line)-len(data) > 2 || (!last && data != line) {
return false
}
return strings.Trim(data, "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/") == ""
}
// lineBreakIn is where the first string under v holding \n, \r or NUL is, or "".
func lineBreakIn(v any, at string) string {
if strings.ContainsAny(at, "\n\r\x00") {
return strings.NewReplacer("\n", `\n`, "\r", `\r`, "\x00", `\0`).Replace(at)
}
switch t := v.(type) {
case string:
if strings.ContainsAny(t, "\n\r\x00") && !pemBlocks(t) {
return at
}
case map[string]any:
for _, k := range sortedKeysAny(t) {
if found := lineBreakIn(t[k], at+"."+k); found != "" {
return found
}
}
case []any:
for i, e := range t {
if found := lineBreakIn(e, fmt.Sprintf("%s[%d]", at, i)); found != "" {
return found
}
}
}
return ""
}
func sortedKeysAny(m map[string]any) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
sort.Strings(out)
return out
}
// UnusedSettings names settings that reach nothing.
//
// Somebody who sets a key on a module with nothing mergeable, or misspells one, has changed
@@ -405,6 +512,9 @@ var networkName = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.-]*$`)
// refused where it is stored (SetSettings, with UnusedSettings) and said where a plan is read,
// and never costs a module its place.
func JudgeSettings(m Manifest, layers []Layer, adopted bool) error {
if err := settingsHoldOneLine(m.Module, layers); err != nil {
return err
}
// With no layers too: a definition may ask for a setting nobody made — an access placed by
// nobody, a file's ${setting:…} nothing sets — and that is the same statement, missing.
if _, err := GivenPorts(m, layers); err != nil {
@@ -0,0 +1,83 @@
package catalogue
import (
"strings"
"testing"
)
// No placement and no access at the machine's own system or the mesh's state, however it is spelled (novox/hq
// issue 339); a module's own place elsewhere is taken.
func TestAPlacementOrAnAccessAtTheMachinesOwnIsRefused(t *testing.T) {
m := Manifest{Module: "notes", Resources: []map[string]any{{"id": "data", "type": "directory"}},
Accesses: []Access{{ID: "media"}}}
for _, path := range []string{"/", "/etc", "/etc/", "/etc/sudoers.d", "/usr/bin", "/root", "/var", "/var/lib",
"/home", "/home/", "/run", "/run/user/1000", "/var/lib/mesh/x", "/var/lib/mesh-host", "/var/lib/mesh-host/identity",
"/srv/../etc", "//etc", "/proc/1", "/sys", "/dev", "/boot/efi", "/bin", "/sbin", "/lib", "/lib64"} {
layers := []Layer{{From: "laptop", Values: map[string]any{PlacesSetting: map[string]any{"data": path}}}}
if _, err := Places(m, layers); err == nil || !strings.Contains(err.Error(), "issue 339") {
t.Errorf("a place at %s: %v", path, err)
}
layers = []Layer{{From: "laptop", Values: map[string]any{AccessesSetting: map[string]any{"media": path}}}}
if _, err := AccessPlaces(m, layers); err == nil || !strings.Contains(err.Error(), "issue 339") {
t.Errorf("an access at %s: %v", path, err)
}
}
for _, path := range []string{"/srv/notes", "/mnt/plex/data", "/storage/media", "/services/media/movies",
"/var/lib/notes/data", "/home/restic/repo", "/var/lib/mesh-store"} {
layers := []Layer{{From: "laptop", Values: map[string]any{PlacesSetting: map[string]any{"data": path}}}}
if got, err := Places(m, layers); err != nil || got["data"].Path != path {
t.Errorf("a place at %s: %v %v", path, got, err)
}
layers = []Layer{{From: "laptop", Values: map[string]any{AccessesSetting: map[string]any{"media": path}}}}
if got, err := AccessPlaces(m, layers); err != nil || got["media"] != path {
t.Errorf("an access at %s: %v %v", path, got, err)
}
}
}
// A line break, a carriage return or a NUL in any string of any setting is refused, at any depth, keys too —
// where a layer is judged, which is both where it is kept and where it is composed (novox/hq issue 339).
func TestASettingHoldsOneLine(t *testing.T) {
m := Manifest{Module: "mailu"}
for _, v := range []any{"a\nDEBUG=1", "a\rb", "a\x00b", []any{"ok", "x\ny"},
map[string]any{"k": []any{"ok", map[string]any{"deep": "x\ny"}}}, map[string]any{"k\nx": "v"}} {
if err := JudgeSettings(m, []Layer{{From: "home", Values: map[string]any{"v": v}}}, false); err == nil ||
!strings.Contains(err.Error(), "line break") {
t.Errorf("%q: %v", v, err)
}
}
if err := JudgeSettings(m, []Layer{{From: "home", Values: map[string]any{"v\nPATH": "x"}}}, false); err == nil {
t.Error("a line break in a key was taken")
}
if err := JudgeSettings(m, []Layer{{From: "home", Values: map[string]any{"v": "one line", "n": 3.0,
"l": []any{"a", "b"}}}}, false); err != nil {
t.Errorf("one line each: %v", err)
}
}
// PEM blocks alone may hold lines: a provider serves its authority's root as a setting. Anything around them, or
// a line in them that is not base64, is refused.
func TestAPEMBlockIsTheOneSettingWithLines(t *testing.T) {
m := Manifest{Module: "route-proxy"}
full := strings.Repeat("MIIB", 16)
pem := "-----BEGIN CERTIFICATE-----\n" + full + "\n" + full + "\nAbCd+/==\n-----END CERTIFICATE-----\n"
for _, ok := range []string{pem, pem + pem, strings.TrimSuffix(pem, "\n"),
"-----BEGIN CERTIFICATE-----\nMIIBeDCCAR2gAwIBAgIQfake000000000000000000000000\n-----END CERTIFICATE-----\n"} {
if err := JudgeSettings(m, []Layer{{From: "anchor", Values: map[string]any{"root": ok}}}, false); err != nil {
t.Errorf("a PEM block: %v", err)
}
}
for _, bad := range []string{
pem + "PATH=/tmp\n",
"PATH=\n" + pem,
"-----BEGIN CERTIFICATE-----\n" + full + "\nPATH=\n-----END CERTIFICATE-----\n",
"-----BEGIN CERTIFICATE-----\nshort\n" + full + "\n-----END CERTIFICATE-----\n",
"-----BEGIN CERTIFICATE-----\n" + full + "\n-----END KEY-----\n",
"-----BEGIN CERTIFICATE-----\n-----END CERTIFICATE-----\n",
"-----BEGIN CERTIFICATE-----\r\n" + full + "\r\n-----END CERTIFICATE-----\r\n",
} {
if err := JudgeSettings(m, []Layer{{From: "anchor", Values: map[string]any{"root": bad}}}, false); err == nil {
t.Errorf("taken: %q", bad)
}
}
}