Validate blob hashes, offsets and lengths from the destination (closes #155)
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:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user