From fcd59dfe6892897edcb8236c4c85041ccec74890 Mon Sep 17 00:00:00 2001 From: harald Date: Wed, 9 Sep 2026 21:54:05 +0200 Subject: [PATCH] security: harden WebDAV server against Slowloris/connection starvation and fix LZ4 bomb protection --- src/crypto.rs | 45 ++++++++++++++++++++++++- src/mount.rs | 92 +++++++++++++++++++++++++++++++++++++++++++-------- 2 files changed, 122 insertions(+), 15 deletions(-) diff --git a/src/crypto.rs b/src/crypto.rs index f29c68b..f50539f 100644 --- a/src/crypto.rs +++ b/src/crypto.rs @@ -436,7 +436,19 @@ pub fn decrypt_chunk( match buffer[0] { COMPRESSION_NONE => Ok(buffer[1..].to_vec()), COMPRESSION_LZ4 => { - let decompressed = lz4_flex::decompress_size_prepended(&buffer[1..]) + let payload = &buffer[1..]; + if payload.len() < 4 { + bail!("LZ4-Chunk beschädigt: Payload zu kurz für Längen-Präfix"); + } + let uncompressed_size = u32::from_le_bytes(payload[0..4].try_into().unwrap()) as usize; + if uncompressed_size > CHUNK_SIZE { + bail!( + "LZ4-Dekomprimierungsfehler: Decompression-Bomb Schutz ausgelöst (angeforderte Größe {} Bytes > Limit {} Bytes)", + uncompressed_size, + CHUNK_SIZE + ); + } + let decompressed = lz4_flex::decompress_size_prepended(payload) .map_err(|e| anyhow::anyhow!("LZ4-Dekomprimierungsfehler im Chunk: {e}"))?; Ok(decompressed) } @@ -704,6 +716,37 @@ mod tests { let recovered_cid_1 = i64::from_le_bytes(unwrapped_1[64..72].try_into().unwrap()); assert_eq!(recovered_cid_1, carrier_node_id); } + + #[test] + fn test_lz4_decompression_bomb_protection() { + use aes_gcm::KeyInit; + let dek = generate_dek(); + let cipher = Aes256Gcm::new_from_slice(&*dek).unwrap(); + let node_id = 999; + let chunk_index = 0; + let aad = build_chunk_aad(node_id, chunk_index); + + // Erstelle präparierte LZ4-Payload mit deklarierter Größe von 5 MB (> 1 MB CHUNK_SIZE) + let mut malicious_plaintext = Vec::new(); + malicious_plaintext.push(COMPRESSION_LZ4); + let fake_uncompressed_size: u32 = 5 * 1024 * 1024; // 5 MB + malicious_plaintext.extend_from_slice(&fake_uncompressed_size.to_le_bytes()); + malicious_plaintext.extend_from_slice(&[0u8; 32]); // Dummy-LZ4-Payload + + let mut nonce_bytes = [0u8; 12]; + rand::RngCore::fill_bytes(&mut rand::rngs::OsRng, &mut nonce_bytes); + let nonce = Nonce::from_slice(&nonce_bytes); + + let mut ct = malicious_plaintext.clone(); + let tag = cipher.encrypt_in_place_detached(nonce, &aad, &mut ct).unwrap(); + let tag_bytes: [u8; 16] = tag.as_slice().try_into().unwrap(); + + // Entschlüsselung muss fehlschlagen, da Dekomprimierungs-Bomb-Schutz greift + let res = decrypt_chunk(&dek, node_id, chunk_index, &ct, &nonce_bytes, &tag_bytes, FORMAT_VERSION_V2); + assert!(res.is_err(), "Dekomprimierungs-Bomb über 1 MB muss abgewiesen werden!"); + let err_msg = res.err().unwrap().to_string(); + assert!(err_msg.contains("Decompression-Bomb Schutz ausgelöst"), "Fehlermeldung erwartet: {}", err_msg); + } } diff --git a/src/mount.rs b/src/mount.rs index c9979c8..4f2f7a3 100644 --- a/src/mount.rs +++ b/src/mount.rs @@ -420,6 +420,12 @@ pub fn is_loopback_host(host_str: &str) -> bool { || host_lower.starts_with("[::1]") } +/// Maximale Anzahl gleichzeitiger Verbindungen zum lokalen WebDAV-Endpunkt (Schutz gegen Socket-Exhaustion). +const MAX_CONCURRENT_DAV_CONNECTIONS: usize = 64; + +/// Timeout für das Lesen von HTTP-Headern (Schutz gegen Slowloris-Angriffe auf Loopback). +const HTTP_HEADER_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(15); + /// Führt die asynchrone WebDAV HTTP-Server-Schleife mit Session-Token & Host-Header Sicherheits-Middleware aus. pub async fn serve_webdav_loop( listener: TcpListener, @@ -427,6 +433,8 @@ pub async fn serve_webdav_loop( token_path_prefix: String, mut shutdown_rx: watch::Receiver, ) { + let conn_semaphore = std::sync::Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_DAV_CONNECTIONS)); + loop { tokio::select! { res = listener.accept() => { @@ -438,27 +446,49 @@ pub async fn serve_webdav_loop( } }; + // RT-01: Schutz gegen Connection-Starvation / Socket-Flooding (CWE-400) + let permit = match conn_semaphore.clone().try_acquire_owned() { + Ok(p) => p, + Err(_) => { + warn!( + "WebDAV-Verbindungslimit ({} aktive Verbindungen) erreicht: Wehre potenziellen Connection-Starvation-Angriff ab.", + MAX_CONCURRENT_DAV_CONNECTIONS + ); + drop(stream); + continue; + } + }; + let io = TokioIo::new(stream); let handler = dav_server.clone(); let expected_prefix = token_path_prefix.clone(); tokio::spawn(async move { + let _permit = permit; // Permit wird bei Verbindungsende automatisch freigegeben + let service = service_fn(move |req| { let h = handler.clone(); let prefix = expected_prefix.clone(); async move { - // 1. Host-Header Validierung (Anti-DNS-Rebinding) - if let Some(host_val) = req.headers().get(hyper::header::HOST) { - if let Ok(host_str) = host_val.to_str() { - if !is_loopback_host(host_str) { - warn!("Abgewiesener Zugriff mit externem Host-Header: {}", host_str); - let res = hyper::Response::builder() - .status(hyper::StatusCode::FORBIDDEN) - .body(dav_server::body::Body::empty()) - .unwrap(); - return Ok::<_, Infallible>(res); - } - } + // 1. RT-02: Strikte Fail-Closed Host-Header Validierung (Anti-DNS-Rebinding & Anti-Spoofing) + let host_valid = match req.headers().get(hyper::header::HOST) { + Some(host_val) => match host_val.to_str() { + Ok(host_str) => is_loopback_host(host_str), + Err(_) => false, + }, + None => false, + }; + + if !host_valid { + warn!( + "Abgewiesener Zugriff: Fehlender, ungültiger oder externer Host-Header ({:?})", + req.headers().get(hyper::header::HOST) + ); + let res = hyper::Response::builder() + .status(hyper::StatusCode::FORBIDDEN) + .body(dav_server::body::Body::empty()) + .unwrap(); + return Ok::<_, Infallible>(res); } // 2. Session-Token Pfadprüfung (Loopback-Schutz gegen unbefugte lokale Prozesse & Browser CSRF) @@ -476,8 +506,13 @@ pub async fn serve_webdav_loop( } }); - if let Err(err) = http1::Builder::new().serve_connection(io, service).await { - // Client-Disconnects im Explorer sind normal + // RT-01: Header-Read-Timeout & TokioTimer zur aktiven Abwehr von Slowloris-Hanging-Sockets + let mut builder = http1::Builder::new(); + builder.timer(hyper_util::rt::TokioTimer::new()); + builder.header_read_timeout(HTTP_HEADER_READ_TIMEOUT); + + if let Err(err) = builder.serve_connection(io, service).await { + // Client-Disconnects im Explorer oder Timeout-Drops sind normal debug!("HTTP-Verbindungsende: {:?}", err); } }); @@ -490,3 +525,32 @@ pub async fn serve_webdav_loop( } } +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_is_loopback_host_validation() { + assert!(is_loopback_host("127.0.0.1")); + assert!(is_loopback_host("127.0.0.1:8443")); + assert!(is_loopback_host("localhost")); + assert!(is_loopback_host("localhost:8443")); + assert!(is_loopback_host("[::1]")); + assert!(is_loopback_host("[::1]:8443")); + + // Abweisung externer Hosts oder DNS-Rebinding-Attacken + assert!(!is_loopback_host("evil.com")); + assert!(!is_loopback_host("attacker.local")); + assert!(!is_loopback_host("192.168.1.50")); + assert!(!is_loopback_host("10.0.0.1")); + assert!(!is_loopback_host("")); + } + + #[test] + fn test_format_drive() { + assert_eq!(format_drive('s'), "S:"); + assert_eq!(format_drive('Z'), "Z:"); + } +} + +