From 7b0f06122166dbebf7275895b5e42e2af79340f7 Mon Sep 17 00:00:00 2001 From: harald Date: Sat, 19 Sep 2026 01:01:09 +0200 Subject: [PATCH] =?UTF-8?q?fix(sync):=20S-02=20=E2=80=94=20platform-indepe?= =?UTF-8?q?ndent=20path=20construction=20avoids=20path=20traversal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/sync.rs | 20 ++++----- tests/sync_test.rs | 110 ++++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 115 insertions(+), 15 deletions(-) diff --git a/src/sync.rs b/src/sync.rs index 18a0f4d..da5d94e 100644 --- a/src/sync.rs +++ b/src/sync.rs @@ -945,21 +945,19 @@ fn collect_and_pull_dir( continue; } - // S-08 Path Traversal Guard: - // Prüfe Komponenten des relativen Pfads und stelle sicher, dass der Pfad nicht ausbricht - for comp in Path::new(&child_rel).components() { - match comp { - std::path::Component::Normal(_) => {} - _ => bail!( + // S-02 & S-08 Path Traversal Guard: + // Plattformunabhängiger Pfadaufbau mit Segment-Validierung + let mut local_child_path = local_target_base.to_path_buf(); + for seg in child_rel.split('/') { + if seg.is_empty() || seg == "." || seg == ".." || seg.contains('\\') { + bail!( "Path traversal Versuch erkannt in relativem Pfad: '{}'", child_rel - ), + ); } + local_child_path.push(seg); } - vault_relative_paths.insert(child_rel.clone()); - let local_child_path = local_target_base.join(&child_rel.replace('/', "\\")); - if !local_child_path.starts_with(local_target_base) { bail!( "Path traversal Versuch erkannt: '{}' bricht aus Zielverzeichnis aus", @@ -967,6 +965,8 @@ fn collect_and_pull_dir( ); } + vault_relative_paths.insert(child_rel.clone()); + if child.is_dir { if !options.dry_run { fs::create_dir_all(&local_child_path)?; diff --git a/tests/sync_test.rs b/tests/sync_test.rs index da51925..9dc5c80 100644 --- a/tests/sync_test.rs +++ b/tests/sync_test.rs @@ -397,11 +397,7 @@ fn test_s01_pull_delete_preserves_local_excluded_and_leak_files() { b"Album Art or Explorer Cache", ) .unwrap(); - fs::write( - restore_dir.join("local_notes.pdf"), - b"Private Local Notes", - ) - .unwrap(); + fs::write(restore_dir.join("local_notes.pdf"), b"Private Local Notes").unwrap(); let (db, dek) = create_test_container(&container_path); @@ -462,3 +458,107 @@ fn test_s01_pull_delete_preserves_local_excluded_and_leak_files() { let _ = fs::remove_dir_all(&temp_root); } +#[test] +fn test_s02_platform_independent_path_construction_and_traversal_rejection() { + let temp_root = + std::env::temp_dir().join(format!("sanctum_s02_test_{}", rand::random::())); + let container_path = temp_root.join("test.sanctum"); + let source_dir = temp_root.join("source"); + let target_dir = temp_root.join("restored"); + + fs::create_dir_all(&source_dir).unwrap(); + let deep_dir = source_dir.join("level1").join("level2").join("level3"); + fs::create_dir_all(&deep_dir).unwrap(); + fs::write(deep_dir.join("deep_doc.txt"), b"Deeply nested content").unwrap(); + + let (db, dek) = create_test_container(&container_path); + + // 1. Push: Übertrage mehrstufige Verzeichnisstruktur in den Tresor + let mut push_opts = SyncOptions::default(); + push_opts.direction = SyncDirection::Push; + push_opts.quiet = true; + + run_sync( + &db, + 0, + &dek, + FORMAT_VERSION, + source_dir.to_str().unwrap(), + "/MultiLevel", + &push_opts, + ) + .expect("Sync push multi-level"); + + // 2. Pull: Prüfe, dass mehrstufige Pfade plattformunabhängig rekonstruiert werden + let mut pull_opts = SyncOptions::default(); + pull_opts.direction = SyncDirection::Pull; + pull_opts.quiet = true; + + run_sync( + &db, + 0, + &dek, + FORMAT_VERSION, + "/MultiLevel", + target_dir.to_str().unwrap(), + &pull_opts, + ) + .expect("Sync pull multi-level"); + + let restored_file = target_dir + .join("level1") + .join("level2") + .join("level3") + .join("deep_doc.txt"); + assert!( + restored_file.exists(), + "Mehrstufige Datei muss korrekt auf dem Host angelegt worden sein" + ); + assert_eq!(fs::read(restored_file).unwrap(), b"Deeply nested content"); + + // 3. Traversal-Abwehr: Bösartiger Knoten mit relativem Ausbruchsversuch + // Simuliere manipulierten Knoten im Tresor + { + let conn = rusqlite::Connection::open(&container_path).unwrap(); + // Erstelle Knoten mit manipuliertem Namen '../evil.txt' + conn.execute( + "INSERT INTO nodes (id, parent_id, name, is_dir, size, created_at, modified_at, is_carrier) + VALUES (9999, 1, '../evil.txt', 0, 10, 100, 100, 0)", + [], + ) + .unwrap(); + } + + let malicious_target = temp_root.join("malicious_target"); + fs::create_dir_all(&malicious_target).unwrap(); + + let pull_res = run_sync( + &db, + 0, + &dek, + FORMAT_VERSION, + "/", + malicious_target.to_str().unwrap(), + &pull_opts, + ); + + assert!( + pull_res.is_err(), + "Sync Pull muss bei manipuliertem Traversal-Pfad abbrechen" + ); + let err_msg = pull_res.unwrap_err().to_string(); + assert!( + err_msg.contains("Path traversal") + || err_msg.contains("Ungültiger Dateiname") + || err_msg.contains("unzulässige Trennzeichen"), + "Fehlermeldung muss Traversal-Erkennung ausweisen: {}", + err_msg + ); + assert!( + !temp_root.join("evil.txt").exists(), + "Es darf niemals eine Datei außerhalb des Zielverzeichnisses erzeugt werden" + ); + + // Aufräumen + let _ = fs::remove_dir_all(&temp_root); +}