Validate blob hashes, offsets and lengths from the destination (closes #155)
check / check (pull_request) Successful in 1m22s
check / check (push) Successful in 3m30s

A blob hash read back from the downloaded snapshot database or the store listing was trusted unchecked. A hostile remote could set a hash such as "aa/../../etc" and have a decrypted blob written outside the cache directory, or feed a short or negative value that panicked a command.

blobDiskCache.path now refuses any key with a path separator, and ReadAt rejects a negative offset or length, bounding so a sum cannot overflow past the check. A new isBlobHash helper gates FetchBlob, shallow and deep verify, and restore: buildBlobIndexes rejects every hash from the snapshot database before any fetch. The blobs/ and metadata/ listings skip a non-conforming name, and short-hash prefixes in log and error text go through a panic-safe shortHash helper.

Model: opus-4-8
This commit was merged in pull request #195.
This commit is contained in:
2026-09-22 16:11:28 +02:00
parent d88ed64489
commit 82c51a5337
10 changed files with 549 additions and 53 deletions
+74 -16
View File
@@ -6,13 +6,17 @@ import (
"io"
"os"
"path/filepath"
"strings"
"sync"
)
// Sentinel errors for blob cache lookups.
var (
errCacheKeyMissing = errors.New("key not in cache")
errCacheReadBeyondBlob = errors.New("read beyond blob size")
errCacheKeyMissing = errors.New("key not in cache")
errCacheReadBeyondBlob = errors.New("read beyond blob size")
errCacheKeyHasSeparator = errors.New(
"cache key contains a path separator")
errCacheNegativeRead = errors.New("negative offset or length")
)
// blobCacheFileMode is the permission mode for cached blob files.
@@ -74,6 +78,11 @@ func newBlobDiskCache(maxBytes int64) (*blobDiskCache, error) {
// Put writes blob data to disk cache. Entries larger than maxBytes are
// silently skipped.
func (c *blobDiskCache) Put(key string, data []byte) error {
p, err := c.path(key)
if err != nil {
return err
}
entrySize := int64(len(data))
c.mu.Lock()
@@ -87,11 +96,12 @@ func (c *blobDiskCache) Put(key string, data []byte) error {
if e, ok := c.items[key]; ok {
c.unlink(e)
c.curBytes -= e.size
_ = os.Remove(c.path(key))
_ = os.Remove(p)
delete(c.items, key)
}
err := os.WriteFile(c.path(key), data, blobCacheFileMode)
err = os.WriteFile(p, data, blobCacheFileMode)
if err != nil {
return fmt.Errorf("writing blob to cache: %w", err)
}
@@ -119,19 +129,26 @@ func (c *blobDiskCache) Put(key string, data []byte) error {
// disk without buffering its entire plaintext (which may be tens of GB)
// in RAM.
func (c *blobDiskCache) PutFromReader(key string, r io.Reader) (int64, error) {
p, err := c.path(key)
if err != nil {
return 0, err
}
c.mu.Lock()
// Remove any prior entry first; we'll re-link after the file is
// written successfully.
if e, ok := c.items[key]; ok {
c.unlink(e)
c.curBytes -= e.size
_ = os.Remove(c.path(key))
_ = os.Remove(p)
delete(c.items, key)
}
c.mu.Unlock()
//nolint:gosec // G304: path() rejects keys with a separator
f, err := os.OpenFile(
c.path(key), os.O_CREATE|os.O_TRUNC|os.O_WRONLY, blobCacheFileMode)
p, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, blobCacheFileMode)
if err != nil {
return 0, fmt.Errorf("creating cache file: %w", err)
}
@@ -140,13 +157,13 @@ func (c *blobDiskCache) PutFromReader(key string, r io.Reader) (int64, error) {
closeErr := f.Close()
if copyErr != nil {
_ = os.Remove(c.path(key))
_ = os.Remove(p)
return written, fmt.Errorf("streaming to cache file: %w", copyErr)
}
if closeErr != nil {
_ = os.Remove(c.path(key))
_ = os.Remove(p)
return written, fmt.Errorf("closing cache file: %w", closeErr)
}
@@ -158,7 +175,7 @@ func (c *blobDiskCache) PutFromReader(key string, r io.Reader) (int64, error) {
// floor — but the restore path passes math.MaxInt64 as maxBytes
// so this branch is effectively unreachable there.
if written > c.maxBytes {
_ = os.Remove(c.path(key))
_ = os.Remove(p)
return written, nil
}
@@ -181,6 +198,11 @@ func (c *blobDiskCache) PutFromReader(key string, r io.Reader) (int64, error) {
// Get reads a cached blob from disk. Returns data and true on hit.
func (c *blobDiskCache) Get(key string) ([]byte, bool) {
p, err := c.path(key)
if err != nil {
return nil, false
}
c.mu.Lock()
c.getCalls++
@@ -195,7 +217,8 @@ func (c *blobDiskCache) Get(key string) ([]byte, bool) {
c.pushFront(e)
c.mu.Unlock()
data, err := os.ReadFile(c.path(key))
//nolint:gosec // G304: path() rejects keys with a separator
data, err := os.ReadFile(p)
if err != nil {
c.mu.Lock()
if e2, ok2 := c.items[key]; ok2 && e2 == e {
@@ -213,6 +236,20 @@ func (c *blobDiskCache) Get(key string) ([]byte, bool) {
// ReadAt reads a slice of a cached blob without loading the entire blob into memory.
func (c *blobDiskCache) ReadAt(key string, offset, length int64) ([]byte, error) {
p, err := c.path(key)
if err != nil {
return nil, err
}
// offset and length come from a blob_chunks row read back from the
// destination. A negative value must be rejected outright; the upper
// bound is checked as length > size-offset (a subtraction) so a huge
// offset+length cannot overflow int64 and slip past the check.
if offset < 0 || length < 0 {
return nil, fmt.Errorf("%w: offset=%d length=%d",
errCacheNegativeRead, offset, length)
}
c.mu.Lock()
c.readAtCalls++
@@ -223,7 +260,7 @@ func (c *blobDiskCache) ReadAt(key string, offset, length int64) ([]byte, error)
return nil, fmt.Errorf("%w: %q", errCacheKeyMissing, key)
}
if offset+length > e.size {
if length > e.size-offset {
c.mu.Unlock()
return nil, fmt.Errorf("%w: offset=%d length=%d size=%d",
@@ -234,7 +271,7 @@ func (c *blobDiskCache) ReadAt(key string, offset, length int64) ([]byte, error)
c.pushFront(e)
c.mu.Unlock()
f, err := os.Open(c.path(key))
f, err := os.Open(p) //nolint:gosec // G304: path() rejects keys with a separator
if err != nil {
return nil, err
}
@@ -276,7 +313,13 @@ func (c *blobDiskCache) Delete(key string) {
c.unlink(e)
delete(c.items, key)
c.curBytes -= e.size
_ = os.Remove(c.path(key))
// The key is already in the map, so it passed path() when it was
// inserted; the error cannot occur here.
p, err := c.path(key)
if err == nil {
_ = os.Remove(p)
}
}
// Keys returns a snapshot of all cached keys. Safe for iteration without
@@ -347,8 +390,18 @@ func (c *blobDiskCache) Close() error {
return os.RemoveAll(c.dir)
}
func (c *blobDiskCache) path(key string) string {
return filepath.Join(c.dir, key)
// path returns the on-disk location of the cache file for key. The key is
// a blob hash read back from the destination and is not trusted: a value
// such as "aa/../../../home/u/.profile" would otherwise make filepath.Join
// escape the cache directory, so a key containing a path separator is
// refused rather than joined.
func (c *blobDiskCache) path(key string) (string, error) {
if strings.ContainsRune(key, '/') ||
strings.ContainsRune(key, filepath.Separator) {
return "", fmt.Errorf("%w: %q", errCacheKeyHasSeparator, key)
}
return filepath.Join(c.dir, key), nil
}
func (c *blobDiskCache) unlink(e *blobDiskCacheEntry) {
@@ -391,5 +444,10 @@ func (c *blobDiskCache) evictLRU() {
c.unlink(victim)
delete(c.items, victim.key)
c.curBytes -= victim.size
_ = os.Remove(c.path(victim.key))
// victim.key was validated by path() on insertion, so this cannot err.
p, err := c.path(victim.key)
if err == nil {
_ = os.Remove(p)
}
}