fix(vfs): V-04 — structured anti-leak shield rules and custom filter list

This commit is contained in:
2026-09-19 09:58:52 +02:00
parent 17908a64fe
commit 45572b7370
4 changed files with 355 additions and 30 deletions
+264 -20
View File
@@ -4,6 +4,8 @@ use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use std::path::Path;
use anyhow::{Context, Result};
use bytes::{Buf, Bytes, BytesMut};
use dav_server::{
davpath::DavPath,
@@ -20,23 +22,128 @@ use crate::carrier::CarrierFs;
use crate::crypto::{decrypt_chunk, encrypt_chunk, CHUNK_SIZE};
use crate::storage::{Database, NodeRecord};
/// Prüft, ob ein Dateiname zu den typischen Windows Explorer Metadaten-, Cache-
/// oder Thumbnail-Dateien gehört (z. B. Thumbs.db, desktop.ini), die standardmäßig
/// im Container blockiert und verborgen werden (Anti-Leak Shield).
/// Exakte Namen von Explorer-, OS- und Desktop-Metadaten (V-04).
pub const LEAK_EXACT_NAMES: &[&str] = &[
"thumbs.db",
"ehthumbs.db",
"ehthumbs_vista.db",
"desktop.ini",
"folder.jpg",
"albumartsmall.jpg",
"autorun.inf",
".ds_store",
".directory",
".fseventsd",
".spotlight-v100",
];
/// Präfixe bekannter temporärer Metadaten und Lock-Dateien (V-04).
pub const LEAK_PREFIXES: &[&str] = &[
"~$", // MS Office temporäre Lock-Dateien (z. B. ~$Document.docx)
"._", // macOS AppleDouble Metadaten-Dateien (z. B. ._Document.pdf)
];
/// Suffixe und Dateiendungen temporärer Caches und unvollständiger Downloads (V-04).
pub const LEAK_SUFFIXES: &[&str] = &[
".tmp",
".temp",
".crdownload", // Google Chrome temporärer Download
".part", // Mozilla Firefox unvollständiger Download
".partial",
"~", // Linux/UNIX Editor-Backups (Vim, Emacs, Gedit)
];
/// Prüft, ob ein Dateiname zu den typischen Windows Explorer-, OS- oder Anwendungs-
/// Metadaten-, Cache- oder Lock-Dateien gehört, die standardmäßig im Container
/// blockiert und verborgen werden (Anti-Leak Shield, V-04).
pub fn is_leak_file(filename: &str) -> bool {
let lower = filename.trim().to_ascii_lowercase();
match lower.as_str() {
"thumbs.db" | "ehthumbs.db" | "ehthumbs_vista.db" | "desktop.ini" | "folder.jpg"
| "albumartsmall.jpg" | "autorun.inf" | ".ds_store" => true,
_ => {
if lower.starts_with("albumart") && (lower.ends_with(".jpg") || lower.ends_with(".ini"))
{
true
} else {
false
}
is_leak_file_with_custom(filename, &[])
}
/// Prüft, ob ein Dateiname gemäß der Standardregeln oder benutzerdefinierten Regeln (V-04)
/// als Leak-Datei blockiert werden soll.
pub fn is_leak_file_with_custom(filename: &str, custom_rules: &[String]) -> bool {
let trimmed = filename.trim();
if trimmed.is_empty() {
return false;
}
// 1. NTFS Alternate Data Streams (ADS) wie "file.txt:Zone.Identifier"
if trimmed.contains(':') {
return true;
}
let lower = trimmed.to_ascii_lowercase();
// 2. Exakte Namen
for &exact in LEAK_EXACT_NAMES {
if lower == exact {
return true;
}
}
// 3. Präfixe
for &prefix in LEAK_PREFIXES {
if lower.starts_with(prefix) {
return true;
}
}
// 4. Suffixe
for &suffix in LEAK_SUFFIXES {
if lower.ends_with(suffix) {
return true;
}
}
// 5. Spezielle Muster
if lower.starts_with("albumart") && (lower.ends_with(".jpg") || lower.ends_with(".ini")) {
return true;
}
if lower.starts_with(".trash") {
return true;
}
// 6. Benutzerdefinierte Regeln
for custom in custom_rules {
let pat = custom.trim().to_ascii_lowercase();
if pat.is_empty() || pat.starts_with('#') {
continue;
}
if pat.starts_with('*') && pat.ends_with('*') && pat.len() > 2 {
let sub = &pat[1..pat.len() - 1];
if lower.contains(sub) {
return true;
}
} else if pat.starts_with('*') {
if lower.ends_with(&pat[1..]) {
return true;
}
} else if pat.ends_with('*') {
if lower.starts_with(&pat[..pat.len() - 1]) {
return true;
}
} else if lower == pat {
return true;
}
}
false
}
/// Lädt eine benutzerdefinierte Liste von Anti-Leak Filterregeln aus einer Textdatei (V-04).
pub fn load_anti_leak_list<P: AsRef<Path>>(path: P) -> Result<Vec<String>> {
let p = path.as_ref();
let content = std::fs::read_to_string(p)
.with_context(|| format!("Konnte Anti-Leak-Listendatei '{}' nicht lesen", p.display()))?;
let mut rules = Vec::new();
for line in content.lines() {
let trimmed = line.trim();
if !trimmed.is_empty() && !trimmed.starts_with('#') {
rules.push(trimmed.to_string());
}
}
Ok(rules)
}
// ---------------------------------------------------------------------------
@@ -516,6 +623,8 @@ pub struct SanctumFs {
carrier_fs: Option<CarrierFs>,
format_version: u32,
anti_leak: bool,
custom_leak_rules: Arc<Vec<String>>,
leak_counter: Arc<AtomicU64>,
vault_id: u32,
last_activity: Arc<AtomicU64>,
}
@@ -534,6 +643,25 @@ impl SanctumFs {
Self::with_vault(db, dek, format_version, anti_leak, 0)
}
pub fn with_options_and_leak_rules(
db: Database,
dek: Zeroizing<[u8; 32]>,
format_version: u32,
anti_leak: bool,
custom_leak_rules: Vec<String>,
) -> Self {
Self::with_carrier_and_leak_rules(
db,
dek,
None,
None,
format_version,
anti_leak,
custom_leak_rules,
0,
)
}
pub fn with_vault(
db: Database,
dek: Zeroizing<[u8; 32]>,
@@ -552,6 +680,28 @@ impl SanctumFs {
format_version: u32,
anti_leak: bool,
vault_id: u32,
) -> Self {
Self::with_carrier_and_leak_rules(
db,
dek,
carrier_dek,
carrier_node_id,
format_version,
anti_leak,
Vec::new(),
vault_id,
)
}
pub fn with_carrier_and_leak_rules(
db: Database,
dek: Zeroizing<[u8; 32]>,
carrier_dek: Option<Zeroizing<[u8; 32]>>,
carrier_node_id: Option<i64>,
format_version: u32,
anti_leak: bool,
custom_leak_rules: Vec<String>,
vault_id: u32,
) -> Self {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
@@ -574,13 +724,14 @@ impl SanctumFs {
let carrier_fs = if vault_id == 1 {
if let (Some(ref c_dek), Some(c_nid)) = (&carrier_dek_guard, carrier_node_id) {
match CarrierFs::load(
match CarrierFs::load_with_leak_rules(
db.clone(),
c_nid,
Arc::new((*c_dek.key()).clone()),
Arc::new((*dek_guard.key()).clone()),
format_version,
anti_leak,
custom_leak_rules.clone(),
) {
Ok(cfs) => Some(cfs),
Err(e) => {
@@ -603,6 +754,8 @@ impl SanctumFs {
carrier_fs,
format_version,
anti_leak,
custom_leak_rules: Arc::new(custom_leak_rules),
leak_counter: Arc::new(AtomicU64::new(0)),
vault_id,
last_activity: Arc::new(AtomicU64::new(now)),
}
@@ -628,6 +781,22 @@ impl SanctumFs {
self.anti_leak
}
pub fn leak_counter(&self) -> Arc<AtomicU64> {
if let Some(ref cfs) = self.carrier_fs {
cfs.leak_counter()
} else {
self.leak_counter.clone()
}
}
pub fn leak_count(&self) -> u64 {
self.leak_counter().load(Ordering::Relaxed)
}
pub fn is_leak(&self, name: &str) -> bool {
self.anti_leak && is_leak_file_with_custom(name, &self.custom_leak_rules)
}
pub fn touch(&self) {
if let Some(ref cfs) = self.carrier_fs {
cfs.touch();
@@ -713,13 +882,14 @@ impl DavFileSystem for SanctumFs {
let (parent_path, file_name) = self.split_parent_and_name(&path_str);
// Anti-Leak Shield: Blockiere Schreib- oder Neuerstellungsversuche für Explorer-Metadaten
if self.anti_leak && is_leak_file(file_name) {
if self.is_leak(file_name) {
if options.create
|| options.create_new
|| options.write
|| options.append
|| options.truncate
{
self.leak_counter.fetch_add(1, Ordering::Relaxed);
debug!(
"Anti-Leak: Blockiere Erstellung/Schreibzugriff für '{}'",
file_name
@@ -821,7 +991,14 @@ impl DavFileSystem for SanctumFs {
let entries: Vec<Result<Box<dyn DavDirEntry>, FsError>> = children
.into_iter()
.filter(|child| !self.anti_leak || !is_leak_file(&child.name))
.filter(|child| {
if self.is_leak(&child.name) {
self.leak_counter.fetch_add(1, Ordering::Relaxed);
false
} else {
true
}
})
.map(|child| {
Ok(Box::new(SanctumDirEntry {
name: child.name,
@@ -876,7 +1053,8 @@ impl DavFileSystem for SanctumFs {
let path_str = Self::path_to_str(path);
let (parent_path, dir_name) = self.split_parent_and_name(&path_str);
if self.anti_leak && is_leak_file(dir_name) {
if self.is_leak(dir_name) {
self.leak_counter.fetch_add(1, Ordering::Relaxed);
return Err(FsError::Forbidden);
}
@@ -980,7 +1158,8 @@ impl DavFileSystem for SanctumFs {
let (to_parent_path, to_name) = self.split_parent_and_name(&to_str);
if self.anti_leak && is_leak_file(to_name) {
if self.is_leak(to_name) {
self.leak_counter.fetch_add(1, Ordering::Relaxed);
return Err(FsError::Forbidden);
}
@@ -1039,7 +1218,8 @@ impl DavFileSystem for SanctumFs {
let (to_parent_path, to_name) = self.split_parent_and_name(&to_str);
if self.anti_leak && is_leak_file(to_name) {
if self.is_leak(to_name) {
self.leak_counter.fetch_add(1, Ordering::Relaxed);
return Err(FsError::Forbidden);
}
@@ -1164,6 +1344,7 @@ mod tests {
#[test]
fn test_is_leak_file() {
// Exakte Namen
assert!(is_leak_file("Thumbs.db"));
assert!(is_leak_file("thumbs.db"));
assert!(is_leak_file("THUMBS.DB"));
@@ -1177,12 +1358,75 @@ mod tests {
assert!(is_leak_file("AlbumArt_{12345}_Small.jpg"));
assert!(is_leak_file("autorun.inf"));
assert!(is_leak_file(".ds_store"));
assert!(is_leak_file(".DS_Store"));
assert!(is_leak_file(".directory"));
assert!(is_leak_file(".fseventsd"));
assert!(is_leak_file(".spotlight-v100"));
// Präfixe (V-04)
assert!(is_leak_file("~$MyDocument.docx"));
assert!(is_leak_file("._Document.pdf"));
// Suffixe (V-04)
assert!(is_leak_file("temp_file.tmp"));
assert!(is_leak_file("cache.temp"));
assert!(is_leak_file("video.crdownload"));
assert!(is_leak_file("archive.tar.gz.part"));
assert!(is_leak_file("bigfile.partial"));
assert!(is_leak_file("notes.txt~"));
// NTFS Alternate Data Streams (ADS, V-04)
assert!(is_leak_file("document.pdf:Zone.Identifier"));
assert!(is_leak_file("file.exe:$DATA"));
// Wildcards (.trash*, V-04)
assert!(is_leak_file(".trash"));
assert!(is_leak_file(".Trash-1000"));
assert!(is_leak_file(".trashes"));
// Harmlos:
assert!(!is_leak_file("secret.txt"));
assert!(!is_leak_file("passwords.kdbx"));
assert!(!is_leak_file("my_folder.jpg.txt"));
assert!(!is_leak_file("desktop_notes.ini.bak"));
assert!(!is_leak_file("temp_report.docx"));
assert!(!is_leak_file("part1_chapter.txt"));
}
#[test]
fn test_v04_custom_anti_leak_rules_and_loader() {
let custom_rules = vec![
"*.secret_log".to_string(),
"debug_*".to_string(),
"*_temp_*".to_string(),
"custom_exact.bin".to_string(),
];
assert!(is_leak_file_with_custom("audit.secret_log", &custom_rules));
assert!(is_leak_file_with_custom("debug_dump.txt", &custom_rules));
assert!(is_leak_file_with_custom("app_temp_cache.dat", &custom_rules));
assert!(is_leak_file_with_custom("custom_exact.bin", &custom_rules));
// Normale Datei wird nicht blockiert
assert!(!is_leak_file_with_custom("regular_file.txt", &custom_rules));
// Test load_anti_leak_list
let temp_dir = tempfile::tempdir().unwrap();
let rule_file = temp_dir.path().join("anti_leak_rules.txt");
std::fs::write(
&rule_file,
"# Kommentarzeile\n*.bak\n\n # Noch ein Kommentar\nprivate_*\n",
)
.unwrap();
let loaded = load_anti_leak_list(&rule_file).unwrap();
assert_eq!(loaded.len(), 2);
assert_eq!(loaded[0], "*.bak");
assert_eq!(loaded[1], "private_*");
assert!(is_leak_file_with_custom("data.bak", &loaded));
assert!(is_leak_file_with_custom("private_keys.pem", &loaded));
assert!(!is_leak_file_with_custom("public_data.txt", &loaded));
}
fn create_test_fs(anti_leak: bool) -> (SanctumFs, tempfile_placeholder::TempDir) {