| 217 |
217 |
|
.await?
|
| 218 |
218 |
|
.or_not_found("attachment", id)?;
|
| 219 |
219 |
|
|
|
220 |
+ |
if !is_valid_blob_hash(&attachment.blob_hash) {
|
|
221 |
+ |
return Err(ApiError::bad_request("Invalid attachment reference"));
|
|
222 |
+ |
}
|
| 220 |
223 |
|
let blob_path = state.data_dir.join("blobs").join(&attachment.blob_hash);
|
| 221 |
224 |
|
if !blob_path.exists() {
|
| 222 |
225 |
|
return Err(ApiError::bad_request("Blob not available locally — sync required"));
|
| 265 |
268 |
|
.await?
|
| 266 |
269 |
|
.or_not_found("attachment", id)?;
|
| 267 |
270 |
|
|
|
271 |
+ |
if !is_valid_blob_hash(&attachment.blob_hash) {
|
|
272 |
+ |
return Err(ApiError::bad_request("Invalid attachment reference"));
|
|
273 |
+ |
}
|
| 268 |
274 |
|
let blob_path = state.data_dir.join("blobs").join(&attachment.blob_hash);
|
| 269 |
275 |
|
if !blob_path.exists() {
|
| 270 |
276 |
|
return Err(ApiError::bad_request("Blob not available locally — sync required"));
|
| 311 |
317 |
|
let blobs_dir = state.data_dir.join("blobs");
|
| 312 |
318 |
|
|
| 313 |
319 |
|
for meta in metas {
|
|
320 |
+ |
// Reject a malformed hash before it can escape the blobs dir (the meta JSON
|
|
321 |
+ |
// is populated during sync and could carry a tampered value).
|
|
322 |
+ |
if !is_valid_blob_hash(&meta.blob_hash) {
|
|
323 |
+ |
tracing::warn!(blob_hash = %meta.blob_hash, "Skipping attachment with malformed blob hash");
|
|
324 |
+ |
continue;
|
|
325 |
+ |
}
|
| 314 |
326 |
|
// Verify blob exists on disk
|
| 315 |
327 |
|
let blob_path = blobs_dir.join(&meta.blob_hash);
|
| 316 |
328 |
|
if !blob_path.exists() {
|
| 363 |
375 |
|
blob_hash: String,
|
| 364 |
376 |
|
filename: String,
|
| 365 |
377 |
|
) -> Result<(), ApiError> {
|
|
378 |
+ |
if !is_valid_blob_hash(&blob_hash) {
|
|
379 |
+ |
return Err(ApiError::bad_request("Invalid attachment reference"));
|
|
380 |
+ |
}
|
| 366 |
381 |
|
let blob_path = state.data_dir.join("blobs").join(&blob_hash);
|
| 367 |
382 |
|
if !blob_path.exists() {
|
| 368 |
383 |
|
return Err(ApiError::bad_request("Attachment not available locally — sync required"));
|
| 399 |
414 |
|
blob_hash: String,
|
| 400 |
415 |
|
destination: String,
|
| 401 |
416 |
|
) -> Result<(), ApiError> {
|
|
417 |
+ |
if !is_valid_blob_hash(&blob_hash) {
|
|
418 |
+ |
return Err(ApiError::bad_request("Invalid attachment reference"));
|
|
419 |
+ |
}
|
| 402 |
420 |
|
let blob_path = state.data_dir.join("blobs").join(&blob_hash);
|
| 403 |
421 |
|
if !blob_path.exists() {
|
| 404 |
422 |
|
return Err(ApiError::bad_request("Attachment not available locally — sync required"));
|
| 412 |
430 |
|
|
| 413 |
431 |
|
// ============ Helpers ============
|
| 414 |
432 |
|
|
|
433 |
+ |
/// True only for a well-formed content-addressed blob hash (64-char lowercase
|
|
434 |
+ |
/// SHA-256 hex). Guards every `blobs/<hash>` path join: a `blob_hash` can arrive
|
|
435 |
+ |
/// from the frontend (`open_email_blob`/`save_email_blob`) or from a synced
|
|
436 |
+ |
/// attachment row (`apply.rs` binds it unvalidated), so a value like
|
|
437 |
+ |
/// `../../../../etc/passwd` must be rejected before it can escape the blobs dir
|
|
438 |
+ |
/// and be copied out or launched (ultra-fuzz Run #27 Security S-1).
|
|
439 |
+ |
pub(crate) fn is_valid_blob_hash(hash: &str) -> bool {
|
|
440 |
+ |
hash.len() == 64
|
|
441 |
+ |
&& hash
|
|
442 |
+ |
.bytes()
|
|
443 |
+ |
.all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
|
|
444 |
+ |
}
|
|
445 |
+ |
|
| 415 |
446 |
|
fn to_response(a: goingson_core::Attachment, data_dir: &Path) -> AttachmentResponse {
|
| 416 |
447 |
|
let has_local_blob = data_dir.join("blobs").join(&a.blob_hash).exists();
|
| 417 |
448 |
|
AttachmentResponse {
|
| 433 |
464 |
|
pub(crate) fn blob_path(data_dir: &Path, hash: &str) -> PathBuf {
|
| 434 |
465 |
|
data_dir.join("blobs").join(hash)
|
| 435 |
466 |
|
}
|
|
467 |
+ |
|
|
468 |
+ |
#[cfg(test)]
|
|
469 |
+ |
mod tests {
|
|
470 |
+ |
use super::is_valid_blob_hash;
|
|
471 |
+ |
|
|
472 |
+ |
#[test]
|
|
473 |
+ |
fn accepts_real_sha256_hex() {
|
|
474 |
+ |
// 64 lowercase hex chars.
|
|
475 |
+ |
let h = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
|
|
476 |
+ |
assert!(is_valid_blob_hash(h));
|
|
477 |
+ |
}
|
|
478 |
+ |
|
|
479 |
+ |
#[test]
|
|
480 |
+ |
fn rejects_traversal_and_malformed() {
|
|
481 |
+ |
assert!(!is_valid_blob_hash("../../../../etc/passwd"));
|
|
482 |
+ |
assert!(!is_valid_blob_hash("..\\..\\windows\\system32"));
|
|
483 |
+ |
assert!(!is_valid_blob_hash("")); // empty
|
|
484 |
+ |
assert!(!is_valid_blob_hash("abc")); // too short
|
|
485 |
+ |
assert!(!is_valid_blob_hash(
|
|
486 |
+ |
"E3B0C44298FC1C149AFBF4C8996FB92427AE41E4649B934CA495991B7852B855"
|
|
487 |
+ |
)); // uppercase not allowed (canonical store is lowercase)
|
|
488 |
+ |
assert!(!is_valid_blob_hash(
|
|
489 |
+ |
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b85/"
|
|
490 |
+ |
)); // 64 len but contains a slash
|
|
491 |
+ |
}
|
|
492 |
+ |
}
|