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
+33 -34
View File
@@ -1,7 +1,6 @@
package vaultik
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
@@ -428,7 +427,7 @@ func (s *restoreSession) downloadNextBlobSet(plan *restorePlan) (bool, error) {
return false, fmt.Errorf("%w: %s", errBlobMissingFromIndex, shortHash(hash))
}
err := s.downloadBlobToCache(hash, blob.CompressedSize)
err := s.downloadBlobToCache(hash, blob.CompressedSize, blob.UncompressedSize)
if err != nil {
return false, fmt.Errorf("downloading blob %s: %w", shortHash(hash), err)
}
@@ -655,32 +654,18 @@ func (v *Vaultik) downloadSnapshotDB(
defer func() { _ = reader.Close() }()
// Read all data
encryptedData, err := io.ReadAll(reader)
if err != nil {
return nil, "", fmt.Errorf("reading encrypted data: %w", err)
}
log.Debug("Downloaded encrypted database",
"size", ubytes(int64(len(encryptedData))))
// Decrypt and decompress using blobgen.Reader
blobReader, err := blobgen.NewReader(bytes.NewReader(encryptedData), identities...)
// Decrypt and decompress straight from the storage stream, then stream
// the plaintext to a temp file. Neither the encrypted bytes nor the
// decrypted database is ever held whole in memory; a snapshot database
// can be large.
blobReader, err := blobgen.NewReader(reader, identities...)
if err != nil {
return nil, "", fmt.Errorf("creating decryption reader: %w", err)
}
defer func() { _ = blobReader.Close() }()
// Read the binary SQLite database
dbData, err := io.ReadAll(blobReader)
if err != nil {
return nil, "", fmt.Errorf("decrypting and decompressing: %w", err)
}
log.Debug("Decrypted database", "size", ubytes(int64(len(dbData))))
db, tempDir, err := v.materializeSnapshotDB(dbData)
db, tempDir, err := v.materializeSnapshotDB(blobReader)
if err != nil {
return nil, "", err
}
@@ -729,13 +714,15 @@ func (v *Vaultik) verifySnapshotDBIdentity(
return nil
}
// materializeSnapshotDB writes the decrypted snapshot database bytes into
// a fresh private (0700) temp directory and opens the file read-only. On
// any failure it removes the directory before returning, so no decrypted
// metadata is left on disk when the open is interrupted or the payload is
// damaged. On success the returned directory is the caller's to remove.
// materializeSnapshotDB streams the decrypted snapshot database into a
// fresh private (0700) temp directory and opens the file read-only. The
// database is copied through an io.Copy buffer rather than read whole into
// memory. On any failure it removes the directory before returning, so no
// decrypted metadata is left on disk when the copy is interrupted or the
// payload is damaged. On success the returned directory is the caller's to
// remove.
func (v *Vaultik) materializeSnapshotDB(
dbData []byte,
dbReader io.Reader,
) (*database.DB, string, error) {
tempDir, err := afero.TempDir(v.Fs, "", "vaultik-restore-")
if err != nil {
@@ -752,12 +739,24 @@ func (v *Vaultik) materializeSnapshotDB(
dbPath := filepath.Join(tempDir, snapshotDBFilename)
err = afero.WriteFile(v.Fs, dbPath, dbData, restoreFileMode)
dbFile, err := v.Fs.OpenFile(
dbPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, restoreFileMode)
if err != nil {
return nil, "", fmt.Errorf("writing database file: %w", err)
return nil, "", fmt.Errorf("creating database file: %w", err)
}
log.Debug("Created restore database", "path", dbPath)
written, copyErr := io.Copy(dbFile, dbReader)
closeErr := dbFile.Close()
if copyErr != nil {
return nil, "", fmt.Errorf("writing database file: %w", copyErr)
}
if closeErr != nil {
return nil, "", fmt.Errorf("closing database file: %w", closeErr)
}
log.Debug("Created restore database", "path", dbPath, "size", ubytes(written))
db, err := database.OpenReadOnly(v.ctx, dbPath)
if err != nil {
@@ -1212,12 +1211,12 @@ func (s *restoreSession) writeFileChunks(
// size, which is what makes multi-GB blobs tractable on machines with
// less RAM than the blob.
func (s *restoreSession) downloadBlobToCache(
blobHash string, expectedSize int64,
blobHash string, compressedSize, uncompressedSize int64,
) error {
start := time.Now()
t0 := time.Now()
rc, err := s.v.FetchAndDecryptBlob(s.ctx, blobHash, expectedSize, s.identities...)
rc, err := s.v.FetchAndDecryptBlob(s.ctx, blobHash, uncompressedSize, s.identities...)
fetchSetupDur := time.Since(t0)
if err != nil {
@@ -1247,7 +1246,7 @@ func (s *restoreSession) downloadBlobToCache(
log.Debug("Streamed blob into disk cache",
"hash", blobHash[:16],
"compressed_bytes", expectedSize,
"compressed_bytes", compressedSize,
"plaintext_bytes", written,
"ms_total", time.Since(start).Milliseconds(),
"ms_fetch_setup", fetchSetupDur.Milliseconds(),