Bound download expansion and escape control chars on the terminal (closes #164)
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:
@@ -6,11 +6,9 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"time"
|
||||
|
||||
"filippo.io/age"
|
||||
"sneak.berlin/go/vaultik/internal/blobgen"
|
||||
"sneak.berlin/go/vaultik/internal/log"
|
||||
)
|
||||
|
||||
// errBlobHashMismatch is returned when a fetched blob's content hash does
|
||||
@@ -30,13 +28,14 @@ var errBlobNotFullyRead = errors.New(
|
||||
// redundant SHA-256 computation.
|
||||
type hashVerifyReader struct {
|
||||
reader *blobgen.Reader // underlying decrypted blob reader (has internal hasher)
|
||||
limited io.Reader // reader bounded to the blob's recorded plaintext size
|
||||
fetcher io.ReadCloser // raw fetched stream (closed on Close)
|
||||
blobHash string // expected double-SHA-256 hex
|
||||
done bool // EOF reached
|
||||
}
|
||||
|
||||
func (h *hashVerifyReader) Read(p []byte) (int, error) {
|
||||
n, err := h.reader.Read(p)
|
||||
n, err := h.limited.Read(p)
|
||||
if errors.Is(err, io.EOF) {
|
||||
h.done = true
|
||||
}
|
||||
@@ -73,11 +72,17 @@ func (h *hashVerifyReader) Close() error {
|
||||
// returns a streaming reader that computes the double-SHA-256 hash on the fly.
|
||||
// The hash is verified when the returned reader is closed (after fully reading).
|
||||
// This avoids buffering the entire blob in memory.
|
||||
//
|
||||
// maxPlaintextSize is the blob's uncompressed_size as recorded in the
|
||||
// snapshot database. Decompression stops with blobgen.ErrOutputTooLarge
|
||||
// once the plaintext exceeds it, so a tampered blob cannot expand without
|
||||
// limit — using the recorded size, not the restoring host's
|
||||
// blob_size_limit, since that config may differ from the backup host's.
|
||||
func (v *Vaultik) FetchAndDecryptBlob(
|
||||
ctx context.Context, blobHash string, expectedSize int64,
|
||||
ctx context.Context, blobHash string, maxPlaintextSize int64,
|
||||
identities ...age.Identity,
|
||||
) (io.ReadCloser, error) {
|
||||
rc, _, err := v.FetchBlob(ctx, blobHash, expectedSize)
|
||||
rc, err := v.FetchBlob(ctx, blobHash)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -91,52 +96,29 @@ func (v *Vaultik) FetchAndDecryptBlob(
|
||||
|
||||
return &hashVerifyReader{
|
||||
reader: reader,
|
||||
limited: blobgen.LimitReader(reader, maxPlaintextSize),
|
||||
fetcher: rc,
|
||||
blobHash: blobHash,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// FetchBlob downloads a blob and returns a reader for the encrypted data.
|
||||
// Times the Storage.Get and Storage.Stat round-trips separately at
|
||||
// debug level so we can see whether the size-only Stat (which is an
|
||||
// extra request on every fetch) is hurting throughput.
|
||||
func (v *Vaultik) FetchBlob(
|
||||
ctx context.Context, blobHash string, expectedSize int64,
|
||||
) (io.ReadCloser, int64, error) {
|
||||
ctx context.Context, blobHash string,
|
||||
) (io.ReadCloser, error) {
|
||||
// blobHash reaches here from the snapshot database, which is not
|
||||
// trusted. Reject a malformed hash before it is spliced into a storage
|
||||
// path (blobHash[:2]/blobHash[2:4]) or a fetch is attempted.
|
||||
if !isBlobHash(blobHash) {
|
||||
return nil, 0, fmt.Errorf("%w: %s", errInvalidBlobHash, shortHash(blobHash))
|
||||
return nil, fmt.Errorf("%w: %s", errInvalidBlobHash, shortHash(blobHash))
|
||||
}
|
||||
|
||||
blobPath := fmt.Sprintf("blobs/%s/%s/%s", blobHash[:2], blobHash[2:4], blobHash)
|
||||
|
||||
t0 := time.Now()
|
||||
rc, err := v.Storage.Get(ctx, blobPath)
|
||||
getDur := time.Since(t0)
|
||||
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("downloading blob %s: %w", shortHash(blobHash), err)
|
||||
return nil, fmt.Errorf("downloading blob %s: %w", shortHash(blobHash), err)
|
||||
}
|
||||
|
||||
t0 = time.Now()
|
||||
info, err := v.Storage.Stat(ctx, blobPath)
|
||||
statDur := time.Since(t0)
|
||||
|
||||
if err != nil {
|
||||
_ = rc.Close()
|
||||
|
||||
return nil, 0, fmt.Errorf("stat blob %s: %w", shortHash(blobHash), err)
|
||||
}
|
||||
|
||||
log.Debug("FetchBlob round-trips",
|
||||
"hash", shortHash(blobHash),
|
||||
"ms_storage_get", getDur.Milliseconds(),
|
||||
"ms_storage_stat", statDur.Milliseconds(),
|
||||
"expected_size", expectedSize,
|
||||
"stat_size", info.Size,
|
||||
)
|
||||
|
||||
return rc, info.Size, nil
|
||||
return rc, nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user