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
Sanctum Release / Build & Release (Windows x86_64) (push) Canceled after 0s
This commit is contained in:
+144
-2
@@ -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)?;
|
||||
|
||||
Reference in New Issue
Block a user