Bound download expansion and escape control chars on the terminal (closes #164)
check / check (pull_request) Successful in 1m47s
check / check (push) Successful in 3m11s

Objects fetched from the store are untrusted; several decode paths let one expand or print without limit.

- blobgen.LimitReader errors past a byte cap (not io.LimitReader silent EOF). DecodeManifest reads through caps on both compressed input and decompressed output, far above any real manifest, so json.Decode cannot buffer a compressible bomb. FetchAndDecryptBlob bounds decompression to the blob recorded uncompressed_size (not the restoring host blob_size_limit).
- downloadSnapshotDB streams straight from storage to its temp file with io.Copy, replacing two ReadAll calls that held the whole database twice.
- FetchBlob drops the per-blob Stat round-trip, its expectedSize parameter and returned size, all of which only fed a debug log.
- TTYHandler and ui.Writer escape control characters in messages, attribute keys/values, and rendered identifiers/paths before colour codes are applied, so a crafted value cannot drive the terminal.

Model: opus-4-8
This commit was merged in pull request #197.
This commit is contained in:
2026-09-22 17:00:35 +02:00
parent 82c51a5337
commit 1244c9e48d
14 changed files with 431 additions and 87 deletions
+22 -3
View File
@@ -23,7 +23,9 @@ import (
"fmt"
"io"
"os"
"strconv"
"time"
"unicode"
"github.com/dustin/go-humanize"
"golang.org/x/term"
@@ -225,17 +227,17 @@ func (w *Writer) Hex(s string) string {
short = s[:hexAbbrevLen] + "..."
}
return w.paint(ansiCyan, short)
return w.paint(ansiCyan, sanitize(short))
}
// Snapshot colorizes a snapshot ID (full, no abbreviation).
func (w *Writer) Snapshot(id string) string {
return w.paint(ansiCyan+ansiBold, id)
return w.paint(ansiCyan+ansiBold, sanitize(id))
}
// Path colorizes a filesystem path.
func (w *Writer) Path(p string) string {
return w.paint(ansiBlue, p)
return w.paint(ansiBlue, sanitize(p))
}
// Size colorizes a byte count using humanize.Bytes.
@@ -310,6 +312,23 @@ func (w *Writer) paint(color, s string) string {
return color + s + ansiReset
}
// sanitize returns s unchanged when every rune in it is printable, and a
// double-quoted, backslash-escaped form (\n, \x1b, …) otherwise. The
// string value formatters escape their argument through this before
// painting: identifiers, paths and symlink targets they render come from
// the snapshot database, which is not trusted, and escaping must happen
// before colour is applied — the painted result already contains the
// escape codes the raw text would otherwise be indistinguishable from.
func sanitize(s string) string {
for _, r := range s {
if !unicode.IsPrint(r) {
return strconv.Quote(s)
}
}
return s
}
// emit writes "<prefix> <body>\n" with the prefix painted in prefixColor
// and the body optionally painted in bodyColor (empty = no body color).
func (w *Writer) emit(prefixColor, prefix, bodyColor, format string, args []any) {