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:
1 parent
d0490bf106
commit
725b9beaac
2 files changed
+60
-8
No files matched your search
@@ -33,18 +33,32 @@ impl BlobStore {
|
||||
}
|
||||
}
|
||||
|
||||
fn path_for(&self, sha_hex: &str) -> PathBuf {
|
||||
self.root
|
||||
.join(&sha_hex[0..2])
|
||||
.join(&sha_hex[2..4])
|
||||
.join(sha_hex)
|
||||
/// 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())
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
Reference in new issue
Block a user