diff --git a/core/src/feed.rs b/core/src/feed.rs index 9f5ee2e..571a13d 100644 --- a/core/src/feed.rs +++ b/core/src/feed.rs @@ -96,8 +96,14 @@ impl Feed { } pub fn parse(text: &str) -> Feed { + // Trailing CRs are line endings, never content: `str::lines` strips + // only one CR before a newline, so a CR run would survive one parse + // and partly vanish on the next, breaking the render fixpoint. Feed { - items: text.lines().map(parse_line).collect(), + items: text + .lines() + .map(|line| parse_line(line.trim_end_matches('\r'))) + .collect(), } } @@ -424,4 +430,34 @@ mod tests { self.meta("nick") } } + + /// xorshift: deterministic pseudo-randomness, dependency-free. + fn next(seed: &mut u64) -> u64 { + *seed ^= *seed << 13; + *seed ^= *seed >> 7; + *seed ^= *seed << 17; + *seed + } + + #[test] + fn parse_never_panics_and_render_reaches_a_fixpoint() { + // Hostile pieces in random order: the parser must survive anything, + // and whatever it accepts must render to a form that re-parses to + // itself, so a store save can never corrupt a feed it loaded. + let pieces = [ + "# ", "# key = value\n", "smol:mail ", "smol:repost ", "\t", "\n", "---\n", + "2026-10-01T12:47:00Z", "a", "mfrggzdfmztwq2lknnwg23tpoa3...", "/", "\u{e9}", + "\r", " ", "#\n", "smol:host_key = ", + ]; + let mut seed = 0x5eed_1234_u64; + for _ in 0..2000 { + let n = (next(&mut seed) % 48) as usize; + let input: String = (0..n) + .map(|_| pieces[(next(&mut seed) % pieces.len() as u64) as usize]) + .collect(); + let once = Feed::parse(&input).render(); + let twice = Feed::parse(&once).render(); + assert_eq!(once, twice, "render must be a fixpoint after parse"); + } + } } diff --git a/daemon/src/http.rs b/daemon/src/http.rs index 5b5a19b..937d971 100644 --- a/daemon/src/http.rs +++ b/daemon/src/http.rs @@ -4,6 +4,7 @@ use std::io::{Read, Write}; use std::net::{TcpListener, TcpStream}; +use std::sync::atomic::{AtomicUsize, Ordering}; use std::time::UNIX_EPOCH; use fumi_core::crypto::sha256; @@ -12,13 +13,50 @@ use smolboid_core::time; use crate::feedstore::FeedStore; const MAX_REQUEST: usize = 8 * 1024; +/// Concurrent connection cap: each connection holds a thread until its socket +/// timeouts, so a flood must be shed rather than absorbed (audit.md). +const MAX_CONNECTIONS: usize = 64; + +static ACTIVE: AtomicUsize = AtomicUsize::new(0); + +/// Decrements the connection count when the handler finishes, including on +/// early returns, so the cap cannot leak slots. +struct ConnGuard; + +impl ConnGuard { + fn acquire() -> Self { + ACTIVE.fetch_add(1, Ordering::Relaxed); + ConnGuard + } +} + +impl Drop for ConnGuard { + fn drop(&mut self) { + ACTIVE.fetch_sub(1, Ordering::Relaxed); + } +} pub fn serve(listener: TcpListener, feeds: FeedStore) -> ! { loop { match listener.accept() { - Ok((stream, _)) => { + Ok((mut stream, _)) => { + if ACTIVE.load(Ordering::Relaxed) >= MAX_CONNECTIONS { + let mut shed = stream.try_clone().ok(); + if let Some(shed) = shed.as_mut() { + let _ = write_simple(shed, 503, "Service Unavailable", "busy\n", true); + } + continue; + } let feeds = feeds.clone(); - std::thread::spawn(move || handle(stream, feeds)); + let spawned = std::thread::Builder::new().spawn(move || { + let _guard = ConnGuard::acquire(); + handle(stream, feeds); + }); + // A failed spawn must not strand a slot: handle acquires its + // own guard, so nothing was counted until the thread ran. + if spawned.is_err() { + eprintln!("http: connection thread failed to spawn"); + } } Err(e) => eprintln!("http: accept failed: {e}"), } @@ -37,26 +75,27 @@ fn handle(mut stream: TcpStream, feeds: FeedStore) { let (method, path, headers) = match parse_request(&request) { Some(parts) => parts, None => { - let _ = write_simple(&mut stream, 400, "Bad Request", "bad request\n"); + let _ = write_simple(&mut stream, 400, "Bad Request", "bad request\n", true); return; } }; - if method != "GET" { - let _ = write_simple(&mut stream, 405, "Method Not Allowed", "GET only\n"); + let head_only = method == "HEAD"; + if method != "GET" && !head_only { + let _ = write_simple(&mut stream, 405, "Method Not Allowed", "GET, HEAD only\n", true); return; } let Some(username) = username_of(path) else { - let _ = write_simple(&mut stream, 404, "Not Found", "not found\n"); + let _ = write_simple(&mut stream, 404, "Not Found", "not found\n", !head_only); return; }; // An admitted author's feed exists from `admit` on; anything else, // including a valid username with no feed, is not there to be served. if !feeds.exists(&username).unwrap_or(false) { - let _ = write_simple(&mut stream, 404, "Not Found", "not found\n"); + let _ = write_simple(&mut stream, 404, "Not Found", "not found\n", !head_only); return; } let Ok(feed) = feeds.load(&username) else { - let _ = write_simple(&mut stream, 404, "Not Found", "not found\n"); + let _ = write_simple(&mut stream, 404, "Not Found", "not found\n", !head_only); return; }; let body = feed.render().into_bytes(); @@ -80,7 +119,10 @@ fn handle(mut stream: TcpStream, feeds: FeedStore) { body.len() )); let _ = stream.write_all(head.as_bytes()); - let _ = stream.write_all(&body); + // HEAD answers with the headers GET would, minus the body (audit.md). + if !head_only { + let _ = stream.write_all(&body); + } } /// `If-None-Match` takes precedence; `If-Modified-Since` applies only without @@ -136,12 +178,22 @@ fn parse_request(request: &[u8]) -> Option<(&str, &str, Vec<(String, String)>)> Some((method, path, headers)) } -fn write_simple(stream: &mut TcpStream, code: u16, label: &str, body: &str) -> std::io::Result<()> { +fn write_simple( + stream: &mut TcpStream, + code: u16, + label: &str, + body: &str, + emit_body: bool, +) -> std::io::Result<()> { write!( stream, - "HTTP/1.1 {code} {label}\r\nContent-Type: text/plain; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + "HTTP/1.1 {code} {label}\r\nContent-Type: text/plain; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", body.len() - ) + )?; + if emit_body { + stream.write_all(body.as_bytes())?; + } + Ok(()) } fn mtime(feeds: &FeedStore, username: &str) -> i64 { @@ -312,4 +364,98 @@ mod tests { } assert_eq!(http_date(0), "Thu, 01 Jan 1970 00:00:00 GMT"); } + + /// xorshift: deterministic pseudo-randomness, dependency-free. + fn next(seed: &mut u64) -> u64 { + *seed ^= *seed << 13; + *seed ^= *seed >> 7; + *seed ^= *seed << 17; + *seed + } + + #[test] + fn head_serves_headers_without_a_body() { + let (_, addr) = with_feed("head"); + let addr: std::net::SocketAddr = addr.parse().unwrap(); + let response = get(&addr, "HEAD /twtxt/tobias.txt HTTP/1.1\r\n\r\n"); + assert!(response.starts_with("HTTP/1.1 200 OK\r\n"), "{response}"); + assert!(response.contains("Content-Length: "), "{response}"); + assert_eq!(response.split("\r\n\r\n").nth(1), Some(""), "HEAD must not emit a body"); + // The same feed via GET still carries one. + let full = get(&addr, "GET /twtxt/tobias.txt HTTP/1.1\r\n\r\n"); + assert!(full.contains("# smol:host_key = ")); + } + + #[test] + fn the_connection_cap_sheds_floods_with_503() { + let (_, addr) = with_feed("cap"); + let addr: std::net::SocketAddr = addr.parse().unwrap(); + + // Fill every slot with a partial request, as slowloris would. + let mut held: Vec = Vec::new(); + for _ in 0..MAX_CONNECTIONS { + let mut s = TcpStream::connect(addr).unwrap(); + s.write_all(b"G").unwrap(); + held.push(s); + } + std::thread::sleep(std::time::Duration::from_millis(500)); + + let mut extra = TcpStream::connect(addr).unwrap(); + extra + .set_read_timeout(Some(std::time::Duration::from_secs(15))) + .unwrap(); + extra.write_all(b"GET /twtxt/tobias.txt HTTP/1.1\r\n\r\n").unwrap(); + // A single recv may split the response; read to the status line's end. + let mut buf = Vec::new(); + while !buf.windows(2).any(|w| w == b"\r\n") { + let mut chunk = [0u8; 512]; + let n = extra.read(&mut chunk).expect("503 response"); + if n == 0 { + break; + } + buf.extend_from_slice(&chunk[..n]); + } + assert!( + buf.starts_with(b"HTTP/1.1 503"), + "the flood was absorbed instead of shed: {}", + String::from_utf8_lossy(&buf) + ); + + // Closing the flood frees the slots and the listener recovers. + drop(held); + let mut served = false; + for _ in 0..40 { + let response = get(&addr, "GET /twtxt/tobias.txt HTTP/1.1\r\n\r\n"); + if response.starts_with("HTTP/1.1 200") { + served = true; + break; + } + std::thread::sleep(std::time::Duration::from_millis(250)); + } + assert!(served, "the listener never recovered after the flood closed"); + } + + #[test] + fn request_parsing_never_panics_and_gates_usernames() { + let alphabet = [ + "GET", "HEAD", " ", "/twtxt/", ".txt", "..", "%2F", "\u{0}", "\r\n", ":", "a", "-", + "\u{e9}", "\r\n\r\n", "smol:mail ", + ]; + let mut seed = 0x5eed_c0de_u64; + for _ in 0..2000 { + let n = (next(&mut seed) % 24) as usize; + let raw: String = (0..n) + .map(|_| alphabet[(next(&mut seed) % alphabet.len() as u64) as usize]) + .collect(); + let bytes = raw.as_bytes(); + if let Some((_, path, _)) = parse_request(bytes) { + if let Some(name) = username_of(path) { + // Whatever the parser accepts must be path-safe. + assert!(crate::feedstore::valid_username(&name), "{name:?}"); + assert!(!name.contains('/')); + assert!(!name.contains('\u{0}')); + } + } + } + } } diff --git a/tests/pentest.sh b/tests/pentest.sh index 84c8719..a568477 100755 --- a/tests/pentest.sh +++ b/tests/pentest.sh @@ -135,6 +135,55 @@ probe "extra-headers-tolerated" "HTTP/1.1 200" 'GET /twtxt/tobias.txt HTTP/1.1\r curl -s -i "http://$HOST:$HTTP_PORT/twtxt/tobias.txt" -H "X-Evil: $(printf 'injected-header')" \ | grep -qi "injected-header" && fail "request header reflected in response" +step "http: HEAD serves headers without a body" +python3 - "$HTTP_PORT" <<'PYEOF' +import socket, sys +s = socket.create_connection(("127.0.0.1", int(sys.argv[1])), timeout=15) +s.sendall(b"HEAD /twtxt/tobias.txt HTTP/1.1\r\n\r\n") +data = b"" +while b"\r\n\r\n" not in data: + chunk = s.recv(4096) + if not chunk: + break + data += chunk +head, _, body = data.partition(b"\r\n\r\n") +assert head.startswith(b"HTTP/1.1 200"), head +assert b"Content-Length:" in head, head +assert body == b"", "HEAD must not emit a body" +print("ok HEAD answers 200 with headers and no body") +PYEOF + +step "http: the connection cap sheds floods with 503" +python3 - "$HTTP_PORT" <<'PYEOF' +import socket, sys, time +port = int(sys.argv[1]) +held = [] +for _ in range(64): + s = socket.create_connection(("127.0.0.1", port), timeout=15) + s.sendall(b"G") # a partial request, as slowloris sends + held.append(s) +time.sleep(0.5) +extra = socket.create_connection(("127.0.0.1", port), timeout=15) +extra.sendall(b"GET /twtxt/tobias.txt HTTP/1.1\r\n\r\n") +data = b"" +while b"\r\n" not in data: + chunk = extra.recv(512) + if not chunk: + break + data += chunk +assert data.startswith(b"HTTP/1.1 503"), f"flood absorbed: {data!r}" +for s in held: + s.close() +extra.close() +print("ok the 65th connection was shed with 503") +PYEOF +# The listener must recover once the flood closes. +for _ in $(seq 1 20); do + curl -sf "http://$HOST:$HTTP_PORT/twtxt/tobias.txt" >/dev/null && break + sleep 1 +done +curl -sf "http://$HOST:$HTTP_PORT/twtxt/tobias.txt" >/dev/null || fail "listener never recovered after the flood" + step "http: slowloris is bounded by the read timeout" python3 - "$HTTP_PORT" <<'EOF' import socket, sys, time