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
+22 -12
View File
@@ -24,9 +24,10 @@ var (
errVerificationFailed = errors.New("verification failed")
errSecretKeyRequired = errors.New(
"VAULTIK_AGE_SECRET_KEY not set; required for deep verification")
errChunksOutOfOrder = errors.New("chunks out of order")
errChunkHashMismatch = errors.New("chunk hash mismatch")
errTrailingBlobData = errors.New(
errChunksOutOfOrder = errors.New("chunks out of order")
errChunkHashMismatch = errors.New("chunk hash mismatch")
errNegativeChunkLength = errors.New("chunk length is negative")
errTrailingBlobData = errors.New(
"blob has unexpected trailing bytes not covered by chunk list")
errManifestExtraBlob = errors.New("manifest contains blob not in database")
errManifestMissingBlob = errors.New(
@@ -421,7 +422,7 @@ func (v *Vaultik) verifyBlob(
}
log.Info("Blob verified",
"hash", blobInfo.Hash[:16]+"...",
"hash", shortHash(blobInfo.Hash)+"...",
"chunks", chunkCount,
"size", ubytes(blobInfo.CompressedSize),
)
@@ -487,21 +488,24 @@ func (v *Vaultik) verifyBlobChunks(
totalRead = offset
}
// Read chunk data
chunkData := make([]byte, length)
// length comes from an untrusted blob_chunks row: reject a
// negative value, and hash by streaming exactly length bytes
// rather than allocating a database-supplied size up front.
if length < 0 {
return 0, fmt.Errorf("%w: offset %d length %d",
errNegativeChunkLength, offset, length)
}
_, err = io.ReadFull(decompressor, chunkData)
hasher := sha256.New()
n, err := io.CopyN(hasher, decompressor, length)
if err != nil {
return 0, fmt.Errorf("failed to read chunk at offset %d: %w", offset, err)
}
totalRead += length
totalRead += n
// Verify chunk hash
hasher := sha256.New()
hasher.Write(chunkData)
calculatedHash := hex.EncodeToString(hasher.Sum(nil))
if calculatedHash != chunkHash {
return 0, fmt.Errorf("%w at offset %d: calculated %s, expected %s",
errChunkHashMismatch, offset, calculatedHash, chunkHash)
@@ -651,6 +655,12 @@ func (v *Vaultik) verifyBlobExistenceFromDB(blobs []snapshot.BlobInfo) error {
log.Info("Verifying blob existence in S3", "blob_count", len(blobs))
for i, blob := range blobs {
// The hash is read from the snapshot database, which is not
// trusted; check it before it is spliced into a storage path.
if !isBlobHash(blob.Hash) {
return fmt.Errorf("%w: %s", errInvalidBlobHash, shortHash(blob.Hash))
}
// Construct blob path
blobPath := fmt.Sprintf("blobs/%s/%s/%s", blob.Hash[:2], blob.Hash[2:4], blob.Hash)