Skip to content

Commit ae2b6ba

Browse files
TeoSlayerteovl
andauthored
Harden app-store install path and cap-state reads (#315)
* Harden app-store install path and cap-state reads Three install-path security fixes in pilotctl appstore: - Path traversal during staging: manifest binary.path was joined onto the bundle and staging dirs with no containment check, so a path like ../../etc/x escaped the staging tree. Add a resolveUnder guard (mirrors the supervisor's) at both join sites; reject any path that resolves outside the target dir. - Unbounded catalogue bundle download: the bundle tarball was copied with no size limit, so a hostile or compromised CDN could exhaust disk before the post-copy sha256 check ran. Cap the compressed download with io.LimitReader (maxBundleBytes) and fail closed when exceeded. - Cap-state reads no longer fail open: loadCapStateRecords silently skipped malformed lines and tampered HMAC records, under-reporting usage and hiding an erased cap. It now returns an error and the caps command fails closed on a malformed line, an HMAC mismatch, or a signed chain spliced with an unauthenticated record. Tests: ../../etc/x and absolute/empty paths rejected; oversized bundle download refused; malformed line and tampered/mixed cap-state records fail closed; legacy and nil-key reads still accepted. * Confine appstore caps app id and annotate guarded gosec paths --------- Co-authored-by: Teodor Calin <teodor@vulturelabs.io>
1 parent 3a71dbb commit ae2b6ba

4 files changed

Lines changed: 281 additions & 49 deletions

File tree

cmd/pilotctl/appstore.go

Lines changed: 106 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -993,6 +993,33 @@ type installReport struct {
993993
// The daemon's supervisor only scans on Start, so an install while the
994994
// daemon is running is invisible until the next daemon restart — the
995995
// success message and JSON note callers about this.
996+
// resolveUnder joins rel onto base, cleans it, and verifies the result
997+
// is contained inside base. Returns an error otherwise.
998+
//
999+
// filepath.Join alone does NOT block traversal: a manifest with
1000+
// binary.path = "../../etc/x" resolves outside the bundle/staging dir,
1001+
// which would let a hostile manifest read an arbitrary host file as the
1002+
// "binary" or plant a copy outside the staging tree. This mirrors the
1003+
// supervisor's resolveUnder guard so the install path enforces the same
1004+
// containment the supervisor relies on at spawn.
1005+
func resolveUnder(base, rel string) (string, error) {
1006+
if rel == "" {
1007+
return "", fmt.Errorf("empty path")
1008+
}
1009+
if filepath.IsAbs(rel) {
1010+
return "", fmt.Errorf("absolute path not permitted")
1011+
}
1012+
absBase, err := filepath.Abs(base)
1013+
if err != nil {
1014+
return "", fmt.Errorf("abs base: %w", err)
1015+
}
1016+
joined := filepath.Clean(filepath.Join(absBase, rel))
1017+
if joined != absBase && !strings.HasPrefix(joined, absBase+string(filepath.Separator)) {
1018+
return "", fmt.Errorf("path %q escapes %s", rel, absBase)
1019+
}
1020+
return joined, nil
1021+
}
1022+
9961023
func cmdAppStoreInstall(args []string) {
9971024
if len(args) < 1 {
9981025
fatalHint("invalid_argument",
@@ -1076,7 +1103,12 @@ func cmdAppStoreInstall(args []string) {
10761103
"%v", err)
10771104
}
10781105
}
1079-
srcBin := filepath.Join(bundleDir, m.Binary.Path)
1106+
srcBin, err := resolveUnder(bundleDir, m.Binary.Path)
1107+
if err != nil {
1108+
fatalHint("invalid_argument",
1109+
"manifest binary.path must be a relative path inside the bundle; refusing a path that escapes the bundle dir",
1110+
"binary path %q escapes bundle: %v", m.Binary.Path, err)
1111+
}
10801112
if _, err := os.Stat(srcBin); err != nil {
10811113
fatalHint("invalid_argument",
10821114
fmt.Sprintf("manifest pins binary at %q", m.Binary.Path),
@@ -1122,7 +1154,17 @@ func cmdAppStoreInstall(args []string) {
11221154
fatalHint("io_error", "check install root permissions", "write manifest: %v", err)
11231155
}
11241156
// Place the binary at the manifest-declared path inside staging.
1125-
dstBin := filepath.Join(stagingDir, m.Binary.Path)
1157+
// Re-apply the containment guard against the staging root: defence
1158+
// in depth so the write target can't escape even if bundleDir and
1159+
// stagingDir ever diverge.
1160+
dstBin, err := resolveUnder(stagingDir, m.Binary.Path)
1161+
if err != nil {
1162+
// #nosec G703 -- stagingDir is appStoreRoot()/<m.ID>.staging; m.ID is reverse-DNS validated by m.Validate() above, so it cannot escape the install root
1163+
_ = os.RemoveAll(stagingDir)
1164+
fatalHint("invalid_argument",
1165+
"manifest binary.path must stay inside the staging dir",
1166+
"binary path %q escapes staging: %v", m.Binary.Path, err)
1167+
}
11261168
if err := os.MkdirAll(filepath.Dir(dstBin), 0o700); err != nil {
11271169
_ = os.RemoveAll(stagingDir)
11281170
fatalHint("io_error", "check install root permissions", "mkdir binary parent: %v", err)
@@ -1505,7 +1547,15 @@ func cmdAppStoreCaps(args []string) {
15051547
"missing app id")
15061548
}
15071549
appID := args[0]
1508-
appDir := filepath.Join(appStoreRoot(), appID)
1550+
// appID is user input; confine it to a single entry under the app
1551+
// store root so a crafted id (e.g. "../../etc") can't read or open
1552+
// files outside the install tree.
1553+
appDir, err := resolveUnder(appStoreRoot(), appID)
1554+
if err != nil {
1555+
fatalHint("invalid_argument",
1556+
"app ids are single directory names; try `pilotctl appstore list`",
1557+
"invalid app id %q: %v", appID, err)
1558+
}
15091559

15101560
mfRaw, err := os.ReadFile(filepath.Join(appDir, "manifest.json"))
15111561
if err != nil {
@@ -1528,7 +1578,15 @@ func cmdAppStoreCaps(args []string) {
15281578
// Derive HMAC key from daemon identity for cap-state integrity.
15291579
// Falls back to nil (no verification) if identity is unavailable.
15301580
hmacKey := loadCapStateHMACKey()
1531-
records := loadCapStateRecords(filepath.Join(appDir, "cap-state.jsonl"), hmacKey)
1581+
records, err := loadCapStateRecords(filepath.Join(appDir, "cap-state.jsonl"), hmacKey)
1582+
if err != nil {
1583+
// Fail closed: a tampered or corrupt spend log must surface as
1584+
// an error, not be papered over with an under-reported usage
1585+
// figure that makes an erased cap look healthy.
1586+
fatalHint("integrity_error",
1587+
"the wallet's cap-state spend log failed its integrity check; do not trust reported usage. Inspect cap-state.jsonl and, if the wallet's identity is intact, let the wallet rewrite it.",
1588+
"cap-state: %v", err)
1589+
}
15321590

15331591
now := time.Now()
15341592
reports := make([]capUsageReport, 0, len(caps))
@@ -1762,21 +1820,39 @@ type capStateRecord struct {
17621820
hmacOK bool
17631821
}
17641822

1765-
// loadCapStateRecords reads the wallet's persistent spend log.
1766-
// When hmacKey is non-nil, records with a "hmac" field are verified
1767-
// against the chained HMAC-SHA256; tampered records are skipped.
1768-
// Records without "hmac" pass through (backward-compat).
1769-
func loadCapStateRecords(path string, hmacKey []byte) []capStateRecord {
1823+
// loadCapStateRecords reads the wallet's persistent spend log, failing
1824+
// closed on any sign of corruption or tampering rather than silently
1825+
// dropping records (which would under-report usage and make a tampered
1826+
// or partially-erased cap look healthy).
1827+
//
1828+
// Behaviour:
1829+
// - a missing file is normal first-run state: returns (nil, nil);
1830+
// - a malformed JSON line is always an error — never silently skipped;
1831+
// - when hmacKey is non-nil and a line carries an "hmac" field, the
1832+
// chained HMAC-SHA256 must verify, else it's a tamper signal (error);
1833+
// - a file that mixes authenticated and unauthenticated records (only
1834+
// reachable by tampering with a signed chain) is rejected;
1835+
// - a wholly-unauthenticated (legacy) file is accepted for read-only
1836+
// reporting even when a key is available — the wallet migrates it to
1837+
// an authenticated chain on its next write.
1838+
func loadCapStateRecords(path string, hmacKey []byte) ([]capStateRecord, error) {
1839+
// #nosec G304 -- path is appDir/cap-state.jsonl where appDir is confined to the app store root by resolveUnder in the sole production caller
17701840
f, err := os.Open(path)
17711841
if err != nil {
1772-
return nil
1842+
if errors.Is(err, os.ErrNotExist) {
1843+
return nil, nil
1844+
}
1845+
return nil, err
17731846
}
17741847
defer f.Close()
17751848
var out []capStateRecord
17761849
var prevHMAC []byte
1850+
sawHMAC, sawPlain := false, false
17771851
scanner := bufio.NewScanner(f)
17781852
scanner.Buffer(make([]byte, 0, 4*1024), 1024*1024)
1853+
lineNo := 0
17791854
for scanner.Scan() {
1855+
lineNo++
17801856
raw := scanner.Bytes()
17811857
if len(raw) == 0 {
17821858
continue
@@ -1785,10 +1861,18 @@ func loadCapStateRecords(path string, hmacKey []byte) []capStateRecord {
17851861
// Parse the full JSON line including optional HMAC.
17861862
var line capStateJSON
17871863
if err := json.Unmarshal(raw, &line); err != nil {
1788-
continue
1864+
// Fail closed: a garbage line is corruption or tampering,
1865+
// not something to drop on the floor.
1866+
return nil, fmt.Errorf("malformed cap-state line %d in %s: %w", lineNo, path, err)
17891867
}
17901868

1791-
rec := capStateRecord{at: line.At, asset: line.Asset, amount: line.Amount, hmacOK: true}
1869+
if line.HMAC != "" {
1870+
sawHMAC = true
1871+
} else {
1872+
sawPlain = true
1873+
}
1874+
1875+
rec := capStateRecord{at: line.At, asset: line.Asset, amount: line.Amount, hmacOK: line.HMAC == ""}
17921876

17931877
// HMAC verification — only when key is available AND this line
17941878
// carries an HMAC field (post-migration or wallet-written).
@@ -1803,24 +1887,23 @@ func loadCapStateRecords(path string, hmacKey []byte) []capStateRecord {
18031887
expected := base64.StdEncoding.EncodeToString(mac.Sum(nil))
18041888

18051889
if !hmac.Equal([]byte(expected), []byte(line.HMAC)) {
1806-
slog.Warn("cap-state record HMAC mismatch — skipping (possible tampering)",
1807-
"path", path,
1808-
"at", line.At.Format(time.RFC3339),
1809-
)
1810-
continue
1890+
return nil, fmt.Errorf("cap-state line %d in %s: HMAC mismatch — spend log tampered with or truncated", lineNo, path)
18111891
}
18121892
rec.hmacOK = true
18131893
prevHMAC, _ = base64.StdEncoding.DecodeString(line.HMAC)
1814-
} else if hmacKey != nil && line.HMAC == "" {
1815-
// Record without HMAC — backward-compat. Accept it,
1816-
// but reset the chain so the next HMAC-bearing record
1817-
// starts a fresh chain.
1818-
prevHMAC = nil
18191894
}
18201895

18211896
out = append(out, rec)
18221897
}
1823-
return out
1898+
if err := scanner.Err(); err != nil {
1899+
return nil, err
1900+
}
1901+
// A signed chain with an unauthenticated record spliced in can only
1902+
// arise from tampering. Reject (only meaningful when we have a key).
1903+
if hmacKey != nil && sawHMAC && sawPlain {
1904+
return nil, fmt.Errorf("cap-state %s mixes authenticated and unauthenticated records — refusing (possible tampering)", path)
1905+
}
1906+
return out, nil
18241907
}
18251908

18261909
// ── actions ────────────────────────────────────────────────────────────

cmd/pilotctl/appstore_catalogue.go

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -386,10 +386,23 @@ func fetchAndUnpackBundle(e catalogueEntry) (string, error) {
386386
}
387387
defer os.Remove(tmpTar.Name())
388388
h := sha256.New()
389-
if _, err := io.Copy(io.MultiWriter(tmpTar, h), body); err != nil {
389+
// Cap the compressed bundle download. Without this a hostile or
390+
// compromised CDN could stream an unbounded body and exhaust disk
391+
// before the sha256 check (which only runs after the full copy) ever
392+
// fires. maxBundleBytes bounds the COMPRESSED tarball; untarUnder
393+
// separately bounds each extracted file against decompression bombs.
394+
// We read one byte past the cap to distinguish "exactly at limit"
395+
// from "over limit" and fail closed on the latter.
396+
limited := io.LimitReader(body, maxBundleBytes+1)
397+
written, err := io.Copy(io.MultiWriter(tmpTar, h), limited)
398+
if err != nil {
390399
_ = tmpTar.Close()
391400
return "", fmt.Errorf("download body: %w", err)
392401
}
402+
if written > maxBundleBytes {
403+
_ = tmpTar.Close()
404+
return "", fmt.Errorf("bundle exceeds max size (%d bytes): %s — refusing", maxBundleBytes, bundleURL)
405+
}
393406
if err := tmpTar.Close(); err != nil {
394407
return "", err
395408
}
@@ -457,10 +470,19 @@ func httpGet(raw string) (io.ReadCloser, error) {
457470
return resp.Body, nil
458471
}
459472

473+
// maxBundleBytes caps the COMPRESSED bundle tarball download in
474+
// fetchAndUnpackBundle. The largest known pilot app is ~4 MiB
475+
// uncompressed; 128 MiB leaves generous headroom for a multi-file
476+
// bundle while bounding an unbounded-body attack from a hostile or
477+
// compromised CDN that would otherwise fill disk before the post-copy
478+
// sha256 check runs. A var (not const) so tests can lower it without
479+
// writing 128 MiB to disk.
480+
var maxBundleBytes int64 = 128 << 20 // 128 MiB
481+
460482
// maxUntarFileSize is the per-file limit for entries extracted by
461483
// untarUnder. 64 MiB covers legitimate bundles (the largest known
462484
// pilot app is ~4 MiB) while bounding decompression bombs that pass
463-
// the 1 MiB tarball download cap via a high compression ratio.
485+
// the bundle download cap via a high compression ratio.
464486
const maxUntarFileSize int64 = 64 << 20 // 64 MiB
465487

466488
// untarUnder writes every entry in r under dst, refusing any path

cmd/pilotctl/appstore_catalogue_test.go

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,74 @@ import (
44
"archive/tar"
55
"bytes"
66
"compress/gzip"
7+
"crypto/sha256"
8+
"encoding/hex"
79
"os"
810
"path/filepath"
911
"strings"
1012
"testing"
1113
)
1214

15+
// buildBundleTar returns a gzipped tar containing a single file, plus
16+
// its sha256 — a minimal valid bundle for fetchAndUnpackBundle tests.
17+
func buildBundleTar(t *testing.T, name string, payload []byte) (gzBytes []byte, sha string) {
18+
t.Helper()
19+
var buf bytes.Buffer
20+
gz := gzip.NewWriter(&buf)
21+
tw := tar.NewWriter(gz)
22+
if err := tw.WriteHeader(&tar.Header{Name: name, Size: int64(len(payload)), Typeflag: tar.TypeReg, Mode: 0o644}); err != nil {
23+
t.Fatal(err)
24+
}
25+
if _, err := tw.Write(payload); err != nil {
26+
t.Fatal(err)
27+
}
28+
if err := tw.Close(); err != nil {
29+
t.Fatal(err)
30+
}
31+
if err := gz.Close(); err != nil {
32+
t.Fatal(err)
33+
}
34+
out := buf.Bytes()
35+
h := sha256.Sum256(out)
36+
return out, hex.EncodeToString(h[:])
37+
}
38+
39+
// TestFetchAndUnpackBundle_RejectsOversizedDownload is the item-3 cap:
40+
// the COMPRESSED bundle download must be bounded so a hostile CDN can't
41+
// stream an unbounded body and exhaust disk before the post-copy sha256
42+
// check ever runs. maxBundleBytes is lowered for the test so we don't
43+
// have to write 128 MiB.
44+
func TestFetchAndUnpackBundle_RejectsOversizedDownload(t *testing.T) {
45+
orig := maxBundleBytes
46+
defer func() { maxBundleBytes = orig }()
47+
48+
dir := t.TempDir()
49+
gzBytes, sha := buildBundleTar(t, "bin/app", bytes.Repeat([]byte("A"), 4096))
50+
tarPath := filepath.Join(dir, "bundle.tar.gz")
51+
if err := os.WriteFile(tarPath, gzBytes, 0o644); err != nil {
52+
t.Fatal(err)
53+
}
54+
entry := catalogueEntry{ID: "io.test.big", BundleURL: "file://" + tarPath, BundleSHA: sha}
55+
56+
// Cap below the bundle size → must refuse.
57+
maxBundleBytes = int64(len(gzBytes) - 1)
58+
if _, err := fetchAndUnpackBundle(entry); err == nil {
59+
t.Fatal("oversized bundle accepted — download cap missing")
60+
} else if !strings.Contains(err.Error(), "exceeds max size") {
61+
t.Fatalf("err %q should mention exceeds max size", err.Error())
62+
}
63+
64+
// Cap above the bundle size → unpacks normally.
65+
maxBundleBytes = int64(len(gzBytes) + 1)
66+
unpacked, err := fetchAndUnpackBundle(entry)
67+
if err != nil {
68+
t.Fatalf("within-cap bundle rejected: %v", err)
69+
}
70+
if _, err := os.Stat(filepath.Join(unpacked, "bin/app")); err != nil {
71+
t.Fatalf("expected extracted bin/app: %v", err)
72+
}
73+
}
74+
1375
// TestUntarUnder_RejectsDecompressionBomb verifies that a tar entry
1476
// exceeding maxUntarFileSize is rejected (decompression bomb guard).
1577
func TestUntarUnder_RejectsDecompressionBomb(t *testing.T) {

0 commit comments

Comments
 (0)