Let only the authority's root hold lines, refuse every line end, and keep places off the runtime's data and any .ssh
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 fail: its merge-check.sh failed: --- FAIL: TestAnUnreadableStoreClearsNothing (0.01s)
mesh/delivery superseded: a newer head of the same pull request

The review of hq issue 339 found the PEM exception too wide (any module, any
key, any label, anything base64), \v, \f, NEL and the Unicode separators
still let a value end a line in some readers, and the spool, /opt, the
container runtimes' data and an account's .ssh still placeable. Lines are now
taken only in step-ca's root setting, as certificates encoding/pem decodes and
x509 parses; every line end is refused; and those paths are the machine's own.
The test certificate is a real one, made for the tests with its key thrown away.
This commit is contained in:
jochen
2026-10-09 00:28:08 +02:00
parent 522f2253e7
commit 1b502a37e0
4 changed files with 137 additions and 78 deletions
+48 -52
View File
@@ -1,10 +1,14 @@
package catalogue
import (
"bytes"
"crypto/x509"
"encoding/json"
"encoding/pem"
"fmt"
"regexp"
"sort"
"strconv"
"strings"
)
@@ -209,18 +213,25 @@ 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
// settingsHoldOneLine refuses a line break, a carriage return, any other line end (lineEnds) 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.
// of the module's own, never a setting — with one exception, the certificate authority's root as certificates
// (certificates), because that is how step-ca serves it.
func settingsHoldOneLine(module string, layers []Layer) error {
for _, layer := range layers {
for _, key := range sortedKeysAny(layer.Values) {
if module == rootModule && key == rootSetting {
if text, ok := layer.Values[key].(string); ok && certificates(text) {
continue
}
}
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 "+
return fmt.Errorf("%s: the setting %s in %q holds a line break, a carriage return, another line end 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)
}
@@ -229,66 +240,51 @@ func settingsHoldOneLine(module string, layers []Layer) error {
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
}
// lineEnds are every character a reader may take as the end of a line: \n and \r, vertical tab and form feed,
// NEL, and the Unicode line and paragraph separators; and NUL, which ends a string early wherever C reads it.
const lineEnds = "\n\r\v\f\x00\u0085\u2028\u2029"
// 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 {
// rootSetting is the one setting that may hold lines: the certificate authority's root, which step-ca serves to
// its consumers as the setting `root` and which they write into a bundle file (the co-located path, issue 038).
const (
rootModule = "step-ca"
rootSetting = "root"
)
// certificates says whether a value is one or more PEM CERTIFICATE blocks and nothing else: each decodes with
// encoding/pem, carries no headers, and parses with x509.ParseCertificate; nothing but a final line break
// surrounds them, and every line ends with \n alone.
func certificates(v string) bool {
if strings.ContainsAny(v, "\r\v\f\x00\u0085\u2028\u2029") {
return false
}
data := strings.TrimRight(line, "=")
if len(line)-len(data) > 2 || (!last && data != line) {
return false
rest := []byte(v)
found := 0
for len(rest) > 0 {
if !bytes.HasPrefix(rest, []byte("-----BEGIN ")) {
return false
}
block, after := pem.Decode(rest)
if block == nil || block.Type != "CERTIFICATE" || len(block.Headers) > 0 {
return false
}
if _, err := x509.ParseCertificate(block.Bytes); err != nil {
return false
}
found++
rest = after
}
return strings.Trim(data, "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/") == ""
return found > 0
}
// 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)
if strings.ContainsAny(at, lineEnds) {
return strconv.Quote(at)
}
switch t := v.(type) {
case string:
if strings.ContainsAny(t, "\n\r\x00") && !pemBlocks(t) {
if strings.ContainsAny(t, lineEnds) {
return at
}
case map[string]any: