fix(store): a blob id never reaches outside the blob store
check / check (push) Canceled after 0s

Max-testing pass on ledger-store (security property, rank 2). Blob ids
come from the journal, which is untrusted input on disk. BlobStore built
paths from any string: an id under four bytes (or with multi-byte
characters) panicked on slicing, and exists() would report on any path
an id like "../../x" reached. get() never served such bytes (it serves
only content whose SHA-256 equals the id), but it read them.

Now only a 64-hex-digit id names a blob; anything else is an error from
get() and false from exists(), touching nothing. Test
a_blob_id_never_reaches_outside_the_blob_store (empty, short, unicode,
traversal, absolute, non-hex, 63/65 digits; uppercase hex still works).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
This commit is contained in:
swaitsandClaude Opus 5.5 committed 2026-10-13 17:10:01 -06:00
1 parent d0490bf106
commit 725b9beaac
2 files changed
+56 -4

No files matched your search

+22 -4
View File
@@ -33,18 +33,32 @@ impl BlobStore {
}
}
fn path_for(&self, sha_hex: &str) -> PathBuf {
/// Where a blob lives, or `None` for anything that isn't a blob id.
///
/// ```text
/// GOAL: map a blob id to its file, and nothing else to anything.
/// SOURCE: docs/receipt-store.md (content-addressed by SHA-256); the
/// max-testing pass (2026-10-13): ids come from the journal,
/// untrusted on disk, and a short one panicked.
/// INVARIANTS:
/// 1. NEVER a path outside the blob directory, whatever the input.
/// 2. ONLY a 64-hex-digit id (a SHA-256) names a blob.
/// ```
fn path_for(&self, sha_hex: &str) -> Option<PathBuf> {
let valid = sha_hex.len() == 64 && sha_hex.bytes().all(|b| b.is_ascii_hexdigit());
valid.then(|| {
self.root
.join(&sha_hex[0..2])
.join(&sha_hex[2..4])
.join(sha_hex)
})
}
/// Store bytes. Returns `(sha256_hex, deduplicated)`. Identical bytes
/// store once: the second put is a no-op that reports `deduplicated`.
pub fn put(&self, bytes: &[u8]) -> Result<(String, bool), StoreError> {
let sha = sha256_hex(bytes);
let path = self.path_for(&sha);
let path = self.path_for(&sha).expect("a SHA-256 is a blob id");
if path.exists() {
return Ok((sha, true));
}
@@ -75,7 +89,10 @@ impl BlobStore {
/// is reported — never served.
pub fn get(&self, sha_hex: &str) -> Result<Vec<u8>, StoreError> {
let sha_hex = sha_hex.to_lowercase();
let bytes = fs::read(self.path_for(&sha_hex))?;
let path = self
.path_for(&sha_hex)
.ok_or_else(|| StoreError::CorruptBlob(sha_hex.clone()))?;
let bytes = fs::read(path)?;
let actual = sha256_hex(&bytes);
if actual != sha_hex {
return Err(StoreError::CorruptBlob(sha_hex));
@@ -86,6 +103,7 @@ impl BlobStore {
/// True when the blob exists (content not verified; use [`Self::get`]
/// for a verifying read).
pub fn exists(&self, sha_hex: &str) -> bool {
self.path_for(&sha_hex.to_lowercase()).exists()
self.path_for(&sha_hex.to_lowercase())
.is_some_and(|p| p.exists())
}
}
+34
View File
@@ -1114,3 +1114,37 @@ fn a_second_writer_is_refused_while_the_first_has_the_books() {
reopened.verify().unwrap();
assert_eq!(reopened.ledger().chart().len(), 1);
}
/// Security (rank 2; max-testing pass on the store, 2026-10-13): a blob id
/// comes from the journal, which is untrusted input on disk. Whatever the
/// id, the blob store never panics and never touches a path outside its
/// own directory: only a 64-hex-digit id names a blob.
#[test]
fn a_blob_id_never_reaches_outside_the_blob_store() {
let dir = test_dir("blob-ids");
let blobs = ledger_store::BlobStore::open(&dir.join("blobs")).unwrap();
let outside = dir.join("secret.txt");
std::fs::write(&outside, b"not a blob").unwrap();
for id in [
"",
"a",
"ab",
"abc",
"é",
"日本語",
"../secret.txt",
"../../secret.txt",
"ab/../../secret.txt",
"/etc/passwd",
"zz",
&"g".repeat(64),
&"a".repeat(63),
&"a".repeat(65),
] {
assert!(blobs.get(id).is_err(), "{id:?} was served");
assert!(!blobs.exists(id), "{id:?} probed a path");
}
let (sha, _) = blobs.put(b"receipt").unwrap();
assert_eq!(blobs.get(&sha).unwrap(), b"receipt");
assert_eq!(blobs.get(&sha.to_uppercase()).unwrap(), b"receipt");
}