feat(security): release v0.7.0 with comprehensive security hardening (S-01 to S-11)
Sanctum Release / Build & Release (Windows x86_64) (push) Canceled after 0s

This commit is contained in:
2026-09-18 19:12:43 +02:00
parent 80a3bd1911
commit 436790abf0
29 changed files with 1553 additions and 277 deletions
+144 -2
View File
@@ -6,11 +6,103 @@ use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use anyhow::{bail, Context, Result};
use sha2::{Digest, Sha256};
use crate::crypto::{decrypt_chunk, encrypt_chunk, CHUNK_SIZE};
use crate::storage::{Database, NodeRecord};
use crate::ui;
use crate::vfs::is_leak_file;
/// Validiert Knotennamen gegen Path-Traversal (CWE-22) und reservierte Windows-Gerätenamen (S-08).
pub fn validate_node_name(name: &str) -> Result<()> {
if name.is_empty() {
bail!("Dateiname darf nicht leer sein.");
}
if name == "." || name == ".." {
bail!("Ungültiger Dateiname (Verzeichnisreferenz verboten): '{}'", name);
}
if name.contains('/') || name.contains('\\') || name.contains('\0') {
bail!(
"Dateiname enthält unzulässige Trennzeichen oder Null-Bytes: '{}'",
name
);
}
for c in name.chars() {
if (c as u32) < 0x20 {
bail!("Dateiname enthält Steuerzeichen: '{}'", name);
}
}
// Windows reservierte Gerätenamen (CON, PRN, AUX, NUL, COM1..9, LPT1..9)
let base_name = if let Some(dot_idx) = name.find('.') {
&name[..dot_idx]
} else {
name
};
let base_upper = base_name.to_ascii_uppercase();
let reserved = [
"CON", "PRN", "AUX", "NUL",
"COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9",
"LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
];
if reserved.contains(&base_upper.as_str()) {
bail!(
"Dateiname '{}' kollidiert mit einem reservierten Windows-Gerätenamen.",
name
);
}
Ok(())
}
/// Berechnet den SHA-256 Hash einer lokalen Datei für verlässliche Checksummen-Vergleiche (S-10).
fn calc_local_file_sha256(path: &Path) -> Result<String> {
let mut file = File::open(path)?;
let mut hasher = Sha256::new();
let mut buffer = [0u8; 64 * 1024];
loop {
let n = file.read(&mut buffer)?;
if n == 0 {
break;
}
hasher.update(&buffer[..n]);
}
Ok(hex::encode(hasher.finalize()))
}
/// Berechnet den SHA-256 Hash der entschlüsselten Nutzdaten eines Vault-Knotens (S-10).
fn calc_vault_node_sha256(
db: &Database,
node: &NodeRecord,
dek: &[u8; 32],
version: u32,
) -> Result<String> {
let mut hasher = Sha256::new();
let total_chunks = if node.size == 0 {
0
} else {
((node.size - 1) / CHUNK_SIZE as u64 + 1) as u32
};
for idx in 0..total_chunks {
let record = db
.read_chunk(node.id, idx)?
.ok_or_else(|| anyhow::anyhow!("Fehlender Chunk {} für Knoten {}", idx, node.id))?;
let decrypted = decrypt_chunk(
dek,
node.id,
idx,
&record.ciphertext,
&record.nonce,
&record.tag,
version,
)?;
hasher.update(&decrypted);
}
Ok(hex::encode(hasher.finalize()))
}
/// Synchronisationsrichtung
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SyncDirection {
@@ -180,6 +272,8 @@ pub fn sync_single_file_to_vault(
checksum: bool,
dry_run: bool,
) -> Result<FileTransferResult> {
validate_node_name(file_name)?;
let meta = fs::metadata(local_path)
.with_context(|| format!("Konnte Metadaten für '{}' nicht lesen", local_path.display()))?;
let local_size = meta.len();
@@ -198,10 +292,22 @@ pub fn sync_single_file_to_vault(
bail!("Pfad-Konflikt: '{}' existiert im Tresor als Ordner", file_name);
}
// Fast Check: Wenn Größe und mtime identisch sind, überspringen
// Fast Check: Wenn Größe und mtime identisch sind, überspringen (ohne --checksum)
if !checksum && node.size == local_size && node.modified_at == local_mtime {
return Ok(FileTransferResult::Skipped { size: local_size });
}
// Deep Check mit --checksum: Echter kryptografischer SHA-256 Hashvergleich (S-10)
if checksum && node.size == local_size {
if let (Ok(local_hash), Ok(vault_hash)) = (
calc_local_file_sha256(local_path),
calc_vault_node_sha256(db, node, dek, version),
) {
if local_hash == vault_hash {
return Ok(FileTransferResult::Skipped { size: local_size });
}
}
}
}
if dry_run {
@@ -264,6 +370,8 @@ pub fn sync_single_file_to_host(
checksum: bool,
dry_run: bool,
) -> Result<FileTransferResult> {
validate_node_name(&node.name)?;
if local_path.exists() {
if let Ok(meta) = fs::metadata(local_path) {
let local_size = meta.len();
@@ -277,6 +385,18 @@ pub fn sync_single_file_to_host(
if !checksum && local_size == node.size && local_mtime == node.modified_at {
return Ok(FileTransferResult::Skipped { size: node.size });
}
// Deep Check mit --checksum: Echter kryptografischer SHA-256 Hashvergleich (S-10)
if checksum && local_size == node.size {
if let (Ok(local_hash), Ok(vault_hash)) = (
calc_local_file_sha256(local_path),
calc_vault_node_sha256(db, node, dek, version),
) {
if local_hash == vault_hash {
return Ok(FileTransferResult::Skipped { size: node.size });
}
}
}
}
}
@@ -457,6 +577,7 @@ fn collect_and_push_dir(
let entry = entry?;
let path = entry.path();
let file_name = entry.file_name().to_string_lossy().to_string();
validate_node_name(&file_name)?;
let rel_path = path
.strip_prefix(base_dir)
@@ -545,7 +666,7 @@ fn collect_and_push_dir(
Ok(())
}
fn delete_orphans_in_vault(
pub fn delete_orphans_in_vault(
db: &Database,
vault_id: u32,
dek: &[u8; 32],
@@ -556,9 +677,15 @@ fn delete_orphans_in_vault(
quiet: bool,
stats: &mut SyncStats,
) -> Result<()> {
let carrier_id = db.find_carrier_node_id()?.unwrap_or(0);
let children = db.list_children_in_vault(current_vault_id, vault_id, dek)?;
for child in children {
// S-03 Carrier Guard: Trägerdatei und übergeordnete Verzeichnisse niemals löschen!
if carrier_id > 0 && (child.id == carrier_id || db.is_descendant_of(carrier_id, child.id).unwrap_or(false)) {
continue;
}
let child_rel = if prefix_rel.is_empty() {
child.name.clone()
} else {
@@ -704,6 +831,8 @@ fn collect_and_pull_dir(
let children = db.list_children_in_vault(vault_node_id, vault_id, dek)?;
for child in children {
validate_node_name(&child.name)?;
let child_rel = if rel_prefix.is_empty() {
child.name.clone()
} else {
@@ -714,9 +843,22 @@ 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!("Path traversal Versuch erkannt in relativem Pfad: '{}'", child_rel),
}
}
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", child_rel);
}
if child.is_dir {
if !options.dry_run {
fs::create_dir_all(&local_child_path)?;