security: harden WebDAV server against Slowloris/connection starvation and fix LZ4 bomb protection
This commit is contained in:
+44
-1
@@ -436,7 +436,19 @@ pub fn decrypt_chunk(
|
|||||||
match buffer[0] {
|
match buffer[0] {
|
||||||
COMPRESSION_NONE => Ok(buffer[1..].to_vec()),
|
COMPRESSION_NONE => Ok(buffer[1..].to_vec()),
|
||||||
COMPRESSION_LZ4 => {
|
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}"))?;
|
.map_err(|e| anyhow::anyhow!("LZ4-Dekomprimierungsfehler im Chunk: {e}"))?;
|
||||||
Ok(decompressed)
|
Ok(decompressed)
|
||||||
}
|
}
|
||||||
@@ -704,6 +716,37 @@ mod tests {
|
|||||||
let recovered_cid_1 = i64::from_le_bytes(unwrapped_1[64..72].try_into().unwrap());
|
let recovered_cid_1 = i64::from_le_bytes(unwrapped_1[64..72].try_into().unwrap());
|
||||||
assert_eq!(recovered_cid_1, carrier_node_id);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+78
-14
@@ -420,6 +420,12 @@ pub fn is_loopback_host(host_str: &str) -> bool {
|
|||||||
|| host_lower.starts_with("[::1]")
|
|| 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.
|
/// Führt die asynchrone WebDAV HTTP-Server-Schleife mit Session-Token & Host-Header Sicherheits-Middleware aus.
|
||||||
pub async fn serve_webdav_loop(
|
pub async fn serve_webdav_loop(
|
||||||
listener: TcpListener,
|
listener: TcpListener,
|
||||||
@@ -427,6 +433,8 @@ pub async fn serve_webdav_loop(
|
|||||||
token_path_prefix: String,
|
token_path_prefix: String,
|
||||||
mut shutdown_rx: watch::Receiver<bool>,
|
mut shutdown_rx: watch::Receiver<bool>,
|
||||||
) {
|
) {
|
||||||
|
let conn_semaphore = std::sync::Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_DAV_CONNECTIONS));
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
tokio::select! {
|
tokio::select! {
|
||||||
res = listener.accept() => {
|
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 io = TokioIo::new(stream);
|
||||||
let handler = dav_server.clone();
|
let handler = dav_server.clone();
|
||||||
let expected_prefix = token_path_prefix.clone();
|
let expected_prefix = token_path_prefix.clone();
|
||||||
|
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
|
let _permit = permit; // Permit wird bei Verbindungsende automatisch freigegeben
|
||||||
|
|
||||||
let service = service_fn(move |req| {
|
let service = service_fn(move |req| {
|
||||||
let h = handler.clone();
|
let h = handler.clone();
|
||||||
let prefix = expected_prefix.clone();
|
let prefix = expected_prefix.clone();
|
||||||
async move {
|
async move {
|
||||||
// 1. Host-Header Validierung (Anti-DNS-Rebinding)
|
// 1. RT-02: Strikte Fail-Closed Host-Header Validierung (Anti-DNS-Rebinding & Anti-Spoofing)
|
||||||
if let Some(host_val) = req.headers().get(hyper::header::HOST) {
|
let host_valid = match req.headers().get(hyper::header::HOST) {
|
||||||
if let Ok(host_str) = host_val.to_str() {
|
Some(host_val) => match host_val.to_str() {
|
||||||
if !is_loopback_host(host_str) {
|
Ok(host_str) => is_loopback_host(host_str),
|
||||||
warn!("Abgewiesener Zugriff mit externem Host-Header: {}", host_str);
|
Err(_) => false,
|
||||||
let res = hyper::Response::builder()
|
},
|
||||||
.status(hyper::StatusCode::FORBIDDEN)
|
None => false,
|
||||||
.body(dav_server::body::Body::empty())
|
};
|
||||||
.unwrap();
|
|
||||||
return Ok::<_, Infallible>(res);
|
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)
|
// 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 {
|
// RT-01: Header-Read-Timeout & TokioTimer zur aktiven Abwehr von Slowloris-Hanging-Sockets
|
||||||
// Client-Disconnects im Explorer sind normal
|
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);
|
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:");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user