Skip to main content

max / goingson

Close blob_hash path traversal and tighten email escaping Security axis remediation (ultra-fuzz Run #27): - SERIOUS: open_email_blob/save_email_blob joined an unvalidated, frontend- or sync-supplied blob_hash straight into the blobs path, so `../../etc/...` escaped the store and was copied out or launched. Add is_valid_blob_hash (64-char lowercase SHA-256 hex) and reject before the join in all four blob commands plus convert_email_attachments (defense in depth: blob_hash is a synced column). - html_escape now also escapes the single quote. - Remove the dead "Use TLS/SSL" account toggle: IMAP is always implicit TLS and SMTP always forces STARTTLS, so the checkbox was fail-secure but misleading. Client now always sends useTls: true. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-06-18 23:31 UTC
Commit: 79ecfb51dd59ae7d7665863dbfd35b11a7e656f7
Parent: d660e8e
3 files changed, +60 insertions, -8 deletions
@@ -99,12 +99,6 @@
99 99 </div>
100 100 <div class="form-group">
101 101 <label class="form-checkbox-label">
102 - <input type="checkbox" id="${idPrefix}-use-tls" name="use_tls" ${values.useTls !== false ? 'checked' : ''}>
103 - <span>Use TLS/SSL</span>
104 - </label>
105 - </div>
106 - <div class="form-group">
107 - <label class="form-checkbox-label">
108 102 <input type="checkbox" id="${idPrefix}-notify" name="notify_new_emails" ${values.notifyNewEmails ? 'checked' : ''}>
109 103 <span>Notify on new emails</span>
110 104 </label>
@@ -409,7 +403,7 @@
409 403 smtpPort: parseInt(form.smtp_port.value),
410 404 username: form.username.value,
411 405 password: form.password.value,
412 - useTls: form.use_tls.checked,
406 + useTls: true, // TLS is always enforced server-side (implicit IMAP TLS + mandatory SMTP STARTTLS); no opt-out.
413 407 archiveFolderName: form.archive_folder_name.value || 'Archive',
414 408 syncIntervalMinutes: syncIntervalValue ? parseInt(syncIntervalValue) : null,
415 409 };
@@ -463,7 +457,7 @@
463 457 smtpPort: parseInt(form.smtp_port.value),
464 458 username: form.username.value,
465 459 password: form.password.value || null,
466 - useTls: form.use_tls.checked,
460 + useTls: true, // TLS is always enforced server-side (implicit IMAP TLS + mandatory SMTP STARTTLS); no opt-out.
467 461 archiveFolderName: form.archive_folder_name.value || 'Archive',
468 462 syncIntervalMinutes: syncIntervalValue ? parseInt(syncIntervalValue) : null,
469 463 };
@@ -217,6 +217,9 @@ pub async fn open_attachment(
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,6 +268,9 @@ pub async fn save_attachment(
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,6 +317,12 @@ pub async fn convert_email_attachments(
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,6 +375,9 @@ pub async fn open_email_blob(
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,6 +414,9 @@ pub async fn save_email_blob(
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,6 +430,19 @@ pub async fn save_email_blob(
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,3 +464,29 @@ fn to_response(a: goingson_core::Attachment, data_dir: &Path) -> AttachmentRespo
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 + }
@@ -487,6 +487,7 @@ fn html_escape(s: &str) -> String {
487 487 .replace('<', "&lt;")
488 488 .replace('>', "&gt;")
489 489 .replace('"', "&quot;")
490 + .replace('\'', "&#39;")
490 491 }
491 492
492 493 /// Creates a new email record (draft or imported).