Keep places and accesses at the terminal, and refuse a line break in any setting
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:
@@ -445,6 +445,9 @@ func settingsCommand(ctx context.Context, args []string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := refuseTerminalSettingsThroughAVerb(before, values, positionals[0], where); err != nil {
|
||||
return err
|
||||
}
|
||||
added, changed, removed := settingsChange(before, values)
|
||||
if len(removed) > 0 && !*replace {
|
||||
return fmt.Errorf("%s on %s: this layer would no longer set %s. A layer is replaced whole; "+
|
||||
@@ -596,6 +599,13 @@ func settingsCommand(ctx context.Context, args []string) error {
|
||||
if len(positionals) != 1 {
|
||||
return errors.New("settings clear <module> [--node <node>]")
|
||||
}
|
||||
before, _, err := inv.Layer(ctx, *node, positionals[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := refuseTerminalSettingsThroughAVerb(before, nil, positionals[0], where); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := inv.ClearSettings(ctx, *node, positionals[0]); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -1051,3 +1061,40 @@ func declaresTools(m catalogue.Manifest) bool {
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// terminalSettings are the keys no verb may change (novox/hq issue 339). `places` says where the node-engine
|
||||
// creates and, as root, owns a module's directories, with an owner the setting names; `accesses` says which of
|
||||
// the machine's paths are mounted into a module's container. Set through a verb, either lets any caller of the
|
||||
// mesh's console — an agent among them — have root hand it a directory, or mount one of the machine's into a
|
||||
// container it reaches. They are the operator's, typed at the controller's terminal.
|
||||
var terminalSettings = []string{catalogue.PlacesSetting, catalogue.AccessesSetting}
|
||||
|
||||
// throughAVerb says whether this process runs a seat verb's command line: the serving controller names the
|
||||
// verb in the environment of every command it runs for one (runVerb), and a person at the terminal runs none.
|
||||
// Every verb route reaches a command through runVerb — the named verbs and the generic `command` alike — so
|
||||
// this is the one place that knows, whatever line the verb composed.
|
||||
func throughAVerb() (string, bool) {
|
||||
verb := os.Getenv(verbVar)
|
||||
return verb, verb != ""
|
||||
}
|
||||
|
||||
// refuseTerminalSettingsThroughAVerb refuses a layer change through a verb that would add, change or remove
|
||||
// places or accesses; a change that leaves both as they were is not refused.
|
||||
func refuseTerminalSettingsThroughAVerb(before, after map[string]any, module, where string) error {
|
||||
verb, through := throughAVerb()
|
||||
if !through {
|
||||
return nil
|
||||
}
|
||||
for _, key := range terminalSettings {
|
||||
was, _ := json.Marshal(before[key])
|
||||
now, _ := json.Marshal(after[key])
|
||||
if string(was) == string(now) {
|
||||
continue
|
||||
}
|
||||
return fmt.Errorf("%s of %s on %s is set at the controller's terminal only, never through a verb (this "+
|
||||
"line came through %q): it says where root creates and owns a module's directories, or which of "+
|
||||
"the machine's paths are mounted into its container, and whoever may call a verb includes agents "+
|
||||
"(novox/hq issue 339). Nothing was changed", key, module, where, verb)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -245,6 +245,14 @@ func (a *verbArguments) commandLine() ([]string, error) {
|
||||
if len(argv) == 0 {
|
||||
return nil, errors.New("command names no command")
|
||||
}
|
||||
// A layer is written through the settings verb, never the generic one (novox/hq issue 339): the
|
||||
// settings verb is where what a verb may not set is refused, and one route is one set of words.
|
||||
// The command refuses places and accesses through any verb as well; this says so before it runs.
|
||||
if argv[0] == "settings" && slices.ContainsFunc(argv[1:], func(w string) bool { return w == "set" || w == "clear" }) {
|
||||
return nil, errors.New("settings are set and cleared through the settings verb, not the generic " +
|
||||
"command; and places and accesses only at the controller's terminal (novox/hq issue 339). " +
|
||||
"Nothing was done")
|
||||
}
|
||||
// The generic verb is no way round the hand-act log (novox/hq to-be 45 §7): a repair through
|
||||
// it says why, as it would through its own verb.
|
||||
if repair := repairingCommand(argv); repair != "" && !slices.ContainsFunc(argv, isWhyFlag) {
|
||||
|
||||
@@ -245,7 +245,7 @@ func TestCommandRunsTheLineAsGiven(t *testing.T) {
|
||||
if err != nil || strings.Join(argv, " ") != "node account g14 jochen" {
|
||||
t.Fatalf("a plain line: %v %v", argv, err)
|
||||
}
|
||||
argv, err = argvFor("command", map[string]any{"command": `settings set dnsmasq '{"a": "b c"}' --node ace`})
|
||||
argv, err = argvFor("command", map[string]any{"command": `settings show dnsmasq '{"a": "b c"}' --node ace`})
|
||||
if err != nil || len(argv) != 6 || argv[3] != `{"a": "b c"}` {
|
||||
t.Fatalf("a quoted word stays one word: %q %v", argv, err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
)
|
||||
|
||||
// Where root creates and owns a module's directories, and which of the machine's paths reach its container,
|
||||
// are said at the controller's terminal alone (novox/hq issue 339): through a verb, a caller who set `places`
|
||||
// to /etc with an owner of its own would have the next push hand it /etc, and one who set `accesses` to /
|
||||
// would have the machine's root mounted into a container.
|
||||
|
||||
// throughVerb runs a verb's call the way the serving controller does: the command line argvFor composes, in
|
||||
// a process that carries the verb in its environment (runVerb), through this binary's own dispatch.
|
||||
func throughVerb(t *testing.T, verb string, args map[string]any) error {
|
||||
t.Helper()
|
||||
argv, err := argvFor(verb, args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
t.Setenv(verbVar, verb)
|
||||
defer os.Unsetenv(verbVar)
|
||||
return runLine(t, argv)
|
||||
}
|
||||
|
||||
// atTheTerminal runs a command line the way the operator does at the controller's terminal: no verb.
|
||||
func atTheTerminal(t *testing.T, argv ...string) error {
|
||||
t.Helper()
|
||||
t.Setenv(verbVar, "")
|
||||
os.Unsetenv(verbVar)
|
||||
return runLine(t, argv)
|
||||
}
|
||||
|
||||
func runLine(t *testing.T, argv []string) error {
|
||||
t.Helper()
|
||||
before := os.Args
|
||||
defer func() { os.Args = before }()
|
||||
os.Args = append([]string{"mesh-controller"}, argv...)
|
||||
return run()
|
||||
}
|
||||
|
||||
func TestPlacesAndAccessesAreRefusedThroughEveryVerb(t *testing.T) {
|
||||
open := aMesh(t)
|
||||
ctx := t.Context()
|
||||
register(t, open, catalogue.Manifest{Module: "notes", Version: "1",
|
||||
Accesses: []catalogue.Access{{ID: "media", Path: "/storage/media", Mode: "read"}},
|
||||
Resources: []map[string]any{{"id": "data", "type": "directory", "mode": "0755"},
|
||||
{"id": "rc", "type": "file", "path": "/etc/notes.conf", "mode": "0644", "content": "x = ${setting:x}\n"}}})
|
||||
if _, err := assign(ctx, open, "laptop", "notes"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
layer := func() string {
|
||||
t.Helper()
|
||||
values, _, err := open.inventory.Layer(ctx, "laptop", "notes")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, _ := json.Marshal(values)
|
||||
return string(raw)
|
||||
}
|
||||
refused := func(what string, err error) {
|
||||
t.Helper()
|
||||
if err == nil || !strings.Contains(err.Error(), "controller's terminal") ||
|
||||
!strings.Contains(err.Error(), "issue 339") {
|
||||
t.Fatalf("%s: %v", what, err)
|
||||
}
|
||||
}
|
||||
|
||||
// The attack the issue reports, through each verb route: nothing is kept.
|
||||
attacks := []map[string]any{
|
||||
{"places": map[string]any{"data": map[string]any{"path": "/srv/notes", "owner": "1001:1001"}}, "x": 1},
|
||||
{"accesses": map[string]any{"media": "/srv/elsewhere"}, "x": 1},
|
||||
}
|
||||
for _, values := range attacks {
|
||||
raw, _ := json.Marshal(values)
|
||||
refused("settings verb, one machine", throughVerb(t, "settings",
|
||||
map[string]any{"module": "notes", "node": "laptop", "values": string(raw)}))
|
||||
refused("settings verb, the whole mesh", throughVerb(t, "settings",
|
||||
map[string]any{"module": "notes", "values": string(raw)}))
|
||||
for _, line := range []string{
|
||||
"settings set notes '" + string(raw) + "' --node laptop",
|
||||
"settings set --node laptop notes '" + string(raw) + "'",
|
||||
"settings set notes '" + string(raw) + "'",
|
||||
} {
|
||||
if err := throughVerb(t, "command", map[string]any{"command": line}); err == nil {
|
||||
t.Fatalf("the command verb ran %q", line)
|
||||
}
|
||||
}
|
||||
if got := layer(); got != "null" && got != "{}" {
|
||||
t.Fatalf("a refused call kept a layer: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// At the terminal the same placement is taken.
|
||||
if err := atTheTerminal(t, "settings", "set", "notes",
|
||||
`{"places":{"data":{"path":"/srv/notes","owner":"1001:1001"}},"x":0}`, "--node", "laptop"); err != nil {
|
||||
t.Fatalf("places at the terminal: %v", err)
|
||||
}
|
||||
// A verb may change another key and keep the placement as it is.
|
||||
if err := throughVerb(t, "settings", map[string]any{"module": "notes", "node": "laptop",
|
||||
"values": `{"places":{"data":{"path":"/srv/notes","owner":"1001:1001"}},"x":1}`}); err != nil {
|
||||
t.Fatalf("another key through the verb: %v", err)
|
||||
}
|
||||
kept := layer()
|
||||
// But not move it, drop it, or clear the layer that holds it.
|
||||
refused("moved through the verb", throughVerb(t, "settings", map[string]any{"module": "notes", "node": "laptop",
|
||||
"values": `{"places":{"data":{"path":"/srv/other","owner":"1001:1001"}},"x":1}`}))
|
||||
refused("dropped through the verb", throughVerb(t, "settings", map[string]any{"module": "notes", "node": "laptop",
|
||||
"values": `{"x":1}`, "replace": "true"}))
|
||||
refused("cleared through the verb", throughVerb(t, "settings",
|
||||
map[string]any{"module": "notes", "node": "laptop", "clear": "true"}))
|
||||
if err := throughVerb(t, "command", map[string]any{"command": "settings clear notes --node laptop"}); err == nil {
|
||||
t.Fatal("the command verb cleared a layer")
|
||||
}
|
||||
if got := layer(); got != kept {
|
||||
t.Fatalf("a refused call changed the layer: %s, was %s", got, kept)
|
||||
}
|
||||
// Reading through a verb still answers.
|
||||
if err := throughVerb(t, "settings", map[string]any{"module": "notes", "node": "laptop"}); err != nil {
|
||||
t.Fatalf("reading through the verb: %v", err)
|
||||
}
|
||||
|
||||
// The machine's own trees are refused from anywhere, the terminal too.
|
||||
for _, path := range []string{"/etc", "/etc/sudoers.d", "/", "/home", "/var/lib", "/var/lib/mesh-host/x", "/srv/../etc"} {
|
||||
err := atTheTerminal(t, "settings", "set", "notes",
|
||||
`{"places":{"data":{"path":"`+path+`","owner":"1001:1001"}},"x":1}`, "--node", "laptop")
|
||||
if err == nil || !strings.Contains(err.Error(), "issue 339") {
|
||||
t.Fatalf("a place at %s at the terminal: %v", path, err)
|
||||
}
|
||||
err = atTheTerminal(t, "settings", "set", "notes",
|
||||
`{"places":{"data":{"path":"/srv/notes","owner":"1001:1001"}},"accesses":{"media":"`+path+`"},"x":1}`,
|
||||
"--node", "laptop")
|
||||
if err == nil || !strings.Contains(err.Error(), "issue 339") {
|
||||
t.Fatalf("an access at %s at the terminal: %v", path, err)
|
||||
}
|
||||
}
|
||||
// A line break in any setting is refused where it is kept, through a verb or at the terminal.
|
||||
for _, x := range []string{`"a\nPATH=/tmp"`, `"a\rb"`, `"a\u0000b"`, `["ok","x\ny"]`, `{"k":"x\ny"}`} {
|
||||
err := throughVerb(t, "settings", map[string]any{"module": "notes", "node": "laptop", "replace": "true",
|
||||
"values": `{"places":{"data":{"path":"/srv/notes","owner":"1001:1001"}},"x":` + x + `}`})
|
||||
if err == nil || !strings.Contains(err.Error(), "line break") {
|
||||
t.Fatalf("a line break in %s: %v", x, err)
|
||||
}
|
||||
}
|
||||
if got := layer(); got != kept {
|
||||
t.Fatalf("a refused call changed the layer: %s, was %s", got, kept)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user