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:
+22
-12
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user