From fc3a6f30ffebcea49c60d0bfbd20e18eb02ffe05 Mon Sep 17 00:00:00 2001 From: gl0w Date: Thu, 10 Sep 2026 05:56:43 +0100 Subject: [PATCH] m4/inbox-harden: rate/bucket/quota/env tuning + abuse suite (101 tests) --- src/inbox.rs | 166 +++++++++++++--- src/main.rs | 149 ++++++++++++--- src/serve.rs | 227 +++++++++++++++++++--- tests/abuse.rs | 507 +++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 961 insertions(+), 88 deletions(-) create mode 100644 tests/abuse.rs diff --git a/src/inbox.rs b/src/inbox.rs index fc59e73..c6f23ef 100644 --- a/src/inbox.rs +++ b/src/inbox.rs @@ -36,37 +36,74 @@ pub const ACK_DELAY_MS: u64 = 500; /// Stored `license_version` for every accepted submission. pub const LICENSE_VERSION: &str = "v1"; -// TODO(m4-harden, issue #10): abuse controls live here. Stubs below are -// wired into the POST path (they currently allow everything) so #10 only -// has to fill in bodies + flags/env tunables, no call-site surgery. - -/// TODO(m4-harden, issue #10): per-IP bucket (2/hr) — wire flags -/// `--rate-per-hour` / `HU13_RATE_PER_HOUR` here. +/// Abuse controls (m4-harden, issue #10): per-IP token bucket + global +/// quota + tunables. Flags/env only, no config file (see `hu13 serve +/// --help`). +/// Per-IP bucket capacity per hour (default; `--rate-per-hour` / +/// `HU13_RATE_PER_HOUR` overrides per serve run). pub const RATE_PER_HOUR: u32 = 2; -/// TODO(m4-harden, issue #10): global quota (files) — wire flags -/// `--quota` / `HU13_QUOTA` here. +/// Global quota default (files; `--quota` / `HU13_QUOTA` overrides). pub const QUOTA_FILES: usize = 500; -/// TODO(m4-harden, issue #10): per-IP token bucket. Currently always -/// allows; #10 implements the bucket + `429` path (already wired). -#[must_use] -pub fn check_rate_limit(_ip: &str) -> bool { - let _ = RATE_PER_HOUR; - true +/// Per-IP token bucket (sliding 1h window, capacity = `limit_per_hour`). +/// Keys are daily-salted hashes only (`salted_ip_hash(ip, date, salt)`) +/// — plaintext IPs never touch disk or logs; memory holds hashes + +/// timestamps. A per-startup random `server_salt` keeps restarts +/// unlinkable; the `yyyy-mm-dd` date rotates the key daily. +/// `429` when the bucket is empty. +pub struct RateLimiter { + limit_per_hour: u32, + buckets: std::sync::Mutex>>, } -/// TODO(m4-harden, issue #10): global quota (`503` when full until drained -/// by hand). Currently always allows; #10 implements the count + `503` -/// path (already wired). -#[must_use] -pub fn check_quota(_inbox_dir: &Path) -> bool { - let _ = QUOTA_FILES; - true +impl RateLimiter { + #[must_use] + pub fn new(limit_per_hour: u32) -> Self { + Self { + limit_per_hour, + buckets: std::sync::Mutex::new(std::collections::HashMap::new()), + } + } + + fn check_at(&self, ip: &str, date: &str, server_salt: &str, now: u64) -> bool { + let key = salted_ip_hash(ip, date, server_salt); + let mut map = self.buckets.lock().unwrap_or_else(|e| e.into_inner()); + let entry = map.entry(key).or_default(); + entry.retain(|&t| now.wrapping_sub(t) < 3600); + if (entry.len() as u32) >= self.limit_per_hour { + return false; + } + entry.push(now); + true + } + + /// Record one accepted POST from `ip`; `false` = over budget (`429`). + #[must_use] + pub fn check(&self, ip: &str, server_salt: &str) -> bool { + self.check_at(ip, &utc_today(), server_salt, unix_secs()) + } } -// TODO(m4-harden, issue #10): `Origin` check + `SameSite=Lax` live at the -// HTTP layer (`serve.rs`); we set no cookies and no CORS today, so there -// is nothing to check yet. #10 adds the check without changing this file. +/// Count stored files (any regular file counts toward the quota). +#[must_use] +pub fn count_inbox_files(inbox_dir: &Path) -> usize { + match std::fs::read_dir(inbox_dir) { + Ok(rd) => rd.flatten().filter(|e| e.path().is_file()).count(), + Err(_) => 0, + } +} + +/// `true` = room left (files < quota); `false` = full (`503` until a +/// human drains the dir by hand). +#[must_use] +pub fn check_quota_with(inbox_dir: &Path, quota: usize) -> bool { + count_inbox_files(inbox_dir) < quota +} + +// `Origin`/`Referer` check + `SameSite=Lax` live at the HTTP layer +// (`serve.rs`): we set no cookies and send no CORS headers, so `SameSite` +// is vacuously satisfied (no `Set-Cookie` ever); the Origin gate fails +// closed on mismatched browser Origins while curl (no Origin) passes. /// License grant — EXACT final text from `DRAFT.md` (receiver-friendly v1). /// The forms, the agents page, and storage all derive from this one @@ -213,11 +250,16 @@ fn char_len_ok(s: &str, max: usize) -> bool { s.chars().count() <= max } -/// Validate parsed pairs for `kind`: honeypot → [`Validation::Honeypot`]; -/// bad input → `Reject(400)`; oversize whole shape is enforced by the HTTP -/// layer (`413`) before we are called. +/// Validate parsed pairs for `kind` with tuned caps: honeypot → +/// [`Validation::Honeypot`]; bad input → `Reject(400)`; oversize whole +/// shape is enforced by the HTTP layer (`413`) before we are called. #[must_use] -pub fn validate_form(kind: InboxType, pairs: &[(String, String)]) -> Validation { +pub fn validate_form_with_limits( + kind: InboxType, + pairs: &[(String, String)], + max_title: usize, + max_body: usize, +) -> Validation { if honeypot_filled(pairs) { return Validation::Honeypot; } @@ -250,8 +292,8 @@ pub fn validate_form(kind: InboxType, pairs: &[(String, String)]) -> Validation if title.trim().is_empty() || body.trim().is_empty() { return Validation::Reject(400); } - if !char_len_ok(&title, MAX_TITLE_CHARS) - || !char_len_ok(&body, MAX_BODY_CHARS) + if !char_len_ok(&title, max_title) + || !char_len_ok(&body, max_body) || !char_len_ok(&handle, MAX_HANDLE_CHARS) || !char_len_ok(&contact, MAX_CONTACT_CHARS) { @@ -269,6 +311,15 @@ pub fn validate_form(kind: InboxType, pairs: &[(String, String)]) -> Validation }) } +/// Validate parsed pairs for `kind` with default caps (wrapper over +/// [`validate_form_with_limits`]). Used by unit tests + external +/// callers; the POST path threads tuned caps via `with_limits`. +#[allow(dead_code)] +#[must_use] +pub fn validate_form(kind: InboxType, pairs: &[(String, String)]) -> Validation { + validate_form_with_limits(kind, pairs, MAX_TITLE_CHARS, MAX_BODY_CHARS) +} + /// `received` page: one-time ack uuid, ZERO reflection of input. The same /// page answers real stores and honeypot drops (no oracle). #[must_use] @@ -898,6 +949,61 @@ mod tests { assert!(!html.contains(" Built site directory to serve - --inbox-dir Inbox directory for POST storage (must sit OUTSIDE --www) - --listen Listen address [default: 0.0.0.0:39313] - -h, --help Print this help + --www Built site directory to serve + --inbox-dir Inbox directory for POST storage (must sit OUTSIDE --www) + --listen Listen address [default: 0.0.0.0:39313] + --max-title Max title chars [default: 300] + --max-body Max body/prompt chars [default: 20000] + --max-bytes Max whole POST bytes [default: 32768] + --rate-per-hour Per-IP POSTs per hour [default: 2] + --quota Global quota in files [default: 500] + -h, --help Print this help -ENV: HU13_WWW, HU13_INBOX_DIR, HU13_LISTEN (flags win). -POSTS: /inbox/issue + /inbox/pr-prompt only (urlencoded, 32KB cap, ack id). +ENV: HU13_WWW, HU13_INBOX_DIR, HU13_LISTEN, HU13_MAX_TITLE, HU13_MAX_BODY, + HU13_MAX_BYTES, HU13_RATE_PER_HOUR, HU13_QUOTA (flags win). + No config file, ever. +POSTS: /inbox/issue + /inbox/pr-prompt only (urlencoded, ack id, no echo). " ); } @@ -213,36 +220,70 @@ fn run_serve(args: &[String]) -> i32 { let mut www: Option = None; let mut inbox_dir: Option = None; let mut listen: Option = None; + let mut max_title: Option = None; + let mut max_body: Option = None; + let mut max_bytes: Option = None; + let mut rate_per_hour: Option = None; + let mut quota: Option = None; + let need = |args: &[String], i: &mut usize, flag: &str| -> Option { + *i += 1; + if *i >= args.len() { + eprintln!("hu13 serve: {flag} needs a value"); + return None; + } + Some(args[*i].clone()) + }; let mut i = 0; while i < args.len() { match args[i].as_str() { "--www" => { - i += 1; - if i >= args.len() { - eprintln!("hu13 serve: --www needs a value"); + let Some(v) = need(args, &mut i, "--www") else { return 2; - } - www = Some(args[i].clone()); + }; + www = Some(v); } "--inbox-dir" => { - i += 1; - if i >= args.len() { - eprintln!("hu13 serve: --inbox-dir needs a value"); + let Some(v) = need(args, &mut i, "--inbox-dir") else { return 2; - } - inbox_dir = Some(args[i].clone()); + }; + inbox_dir = Some(v); } "--listen" => { - i += 1; - if i >= args.len() { - eprintln!("hu13 serve: --listen needs a value"); + let Some(v) = need(args, &mut i, "--listen") else { return 2; - } - listen = Some(args[i].clone()); + }; + listen = Some(v); + } + "--max-title" => { + let Some(v) = need(args, &mut i, "--max-title") else { + return 2; + }; + max_title = Some(v); + } + "--max-body" => { + let Some(v) = need(args, &mut i, "--max-body") else { + return 2; + }; + max_body = Some(v); + } + "--max-bytes" => { + let Some(v) = need(args, &mut i, "--max-bytes") else { + return 2; + }; + max_bytes = Some(v); + } + "--rate-per-hour" => { + let Some(v) = need(args, &mut i, "--rate-per-hour") else { + return 2; + }; + rate_per_hour = Some(v); + } + "--quota" => { + let Some(v) = need(args, &mut i, "--quota") else { + return 2; + }; + quota = Some(v); } - // TODO(m4-harden, issue #10): cap tunables land here: - // --max-title/--max-body/--max-bytes/--rate-per-hour/--quota - // (+ HU13_* env). No config file, ever. s => { eprintln!("hu13 serve: unknown option '{s}'"); return 2; @@ -250,6 +291,17 @@ fn run_serve(args: &[String]) -> i32 { } i += 1; } + fn parse_num(raw: Option, env: &str, what: &str, dflt: usize) -> Option { + let s = raw.or_else(|| std::env::var(env).ok().filter(|v| !v.trim().is_empty())); + let Some(s) = s else { return Some(dflt) }; + match s.trim().parse::() { + Ok(n) if n > 0 => Some(n), + _ => { + eprintln!("hu13 serve: {what} (else {env}) must be a positive number, got '{s}'"); + None + } + } + } let www = env_or(www, "HU13_WWW"); let inbox_dir = env_or(inbox_dir, "HU13_INBOX_DIR"); let listen = env_or(listen, "HU13_LISTEN").unwrap_or_else(|| "0.0.0.0:39313".to_string()); @@ -257,11 +309,52 @@ fn run_serve(args: &[String]) -> i32 { eprintln!("hu13 serve: usage: hu13 serve --www --inbox-dir [--listen ]"); return 2; }; - serve::run(&serve::ServeOptions { - www: std::path::PathBuf::from(www), - inbox_dir: std::path::PathBuf::from(inbox_dir), + let Some(max_title) = parse_num( + max_title, + "HU13_MAX_TITLE", + "--max-title", + crate::inbox::MAX_TITLE_CHARS, + ) else { + return 2; + }; + let Some(max_body) = parse_num( + max_body, + "HU13_MAX_BODY", + "--max-body", + crate::inbox::MAX_BODY_CHARS, + ) else { + return 2; + }; + let Some(max_bytes) = parse_num( + max_bytes, + "HU13_MAX_BYTES", + "--max-bytes", + crate::inbox::MAX_REQUEST_BYTES, + ) else { + return 2; + }; + let Some(rate_per_hour) = parse_num( + rate_per_hour, + "HU13_RATE_PER_HOUR", + "--rate-per-hour", + crate::inbox::RATE_PER_HOUR as usize, + ) else { + return 2; + }; + let Some(quota) = parse_num(quota, "HU13_QUOTA", "--quota", crate::inbox::QUOTA_FILES) else { + return 2; + }; + let mut opts = serve::ServeOptions::with_defaults( + std::path::PathBuf::from(www), + std::path::PathBuf::from(inbox_dir), listen, - }) + ); + opts.max_title = max_title; + opts.max_body = max_body; + opts.max_bytes = max_bytes; + opts.rate_per_hour = rate_per_hour as u32; + opts.quota = quota; + serve::run(&opts) } fn run_inbox(args: &[String]) -> i32 { diff --git a/src/serve.rs b/src/serve.rs index 53c61b9..bd878f0 100644 --- a/src/serve.rs +++ b/src/serve.rs @@ -9,15 +9,21 @@ //! (only real files serve; anything else is a generic `404` that echoes //! nothing); `..` / NUL / bad escapes never escape www. //! - POSTs: urlencoded-only (`multipart/*` → `400`), `Content-Length` -//! required (absent/unparsable → drop the connection), whole body capped -//! at [`crate::inbox::MAX_REQUEST_BYTES`] (`413`), 5s read timeout -//! (stall → drop), strict UTF-8 (`400`), NUL → `400`, controls stripped -//! except `\n\t`, caps validated, license required (`400`), honeypot → -//! fake `received` + zero files, fixed ~500ms delay before every -//! inbox-route response, `received` page shows the ack uuid once with -//! zero reflection. Wrong method on the two routes → `405`. -//! - TODO(m4-harden, issue #10): rate bucket (`429`), quota (`503`), -//! `Origin` check hook at the top of [`handle_inbox`]. +//! required (absent/unparsable → drop the connection), whole body +//! capped (`413`, default 32KB, tunable), 5s read timeout (stall → +//! drop), strict UTF-8 (`400`), NUL → `400`, controls stripped except +//! newline/tab, caps validated, license required (`400`), honeypot → +//! fake `received` + zero files, per-IP token bucket (`429`), global +//! quota (`503` until drained), `Origin` gate (`403`, curl with no +//! Origin passes), fixed ~500ms delay before every inbox-route +//! response (success and failure alike — no timing oracle), `received` +//! page shows the ack uuid once with zero reflection. Wrong method → +//! `405`. +//! - Every inbox-route response carries `nosniff` + `no-store`; we set +//! no cookies and send no CORS headers, so `SameSite=Lax` is +//! vacuously satisfied (no `Set-Cookie` ever). Full-disk/IO failure → +//! `503` (lose one POST, never the repo). Caps tunable via flags/env, +//! no config. use std::io::{Read, Write}; use std::net::{TcpListener, TcpStream}; @@ -35,6 +41,45 @@ pub struct ServeOptions { pub inbox_dir: PathBuf, /// Listen address, e.g. `0.0.0.0:39313` (`127.0.0.1:0` in tests). pub listen: String, + /// Max chars for `title` (`--max-title` / `HU13_MAX_TITLE`). + pub max_title: usize, + /// Max chars for `body`/`prompt` (`--max-body` / `HU13_MAX_BODY`). + pub max_body: usize, + /// Max whole POST body bytes (`--max-bytes` / `HU13_MAX_BYTES`). + pub max_bytes: usize, + /// Per-IP POSTs per hour (`--rate-per-hour` / `HU13_RATE_PER_HOUR`). + pub rate_per_hour: u32, + /// Global quota in files (`--quota` / `HU13_QUOTA`). + pub quota: usize, +} + +impl ServeOptions { + /// Defaults matching [`crate::inbox`] constants (m4 core caps). + #[must_use] + pub fn with_defaults(www: PathBuf, inbox_dir: PathBuf, listen: String) -> Self { + Self { + www, + inbox_dir, + listen, + max_title: inbox::MAX_TITLE_CHARS, + max_body: inbox::MAX_BODY_CHARS, + max_bytes: inbox::MAX_REQUEST_BYTES, + rate_per_hour: inbox::RATE_PER_HOUR, + quota: inbox::QUOTA_FILES, + } + } +} + +/// Per-connection shared state (tuned caps + rate buckets + daily salt). +struct Shared { + www: PathBuf, + inbox_dir: PathBuf, + salt: String, + limiter: inbox::RateLimiter, + max_title: usize, + max_body: usize, + max_bytes: usize, + quota: usize, } /// Lexically absolutize + normalize `p` (`.`/`..` resolved, no fs touch so @@ -126,14 +171,21 @@ pub fn run(opts: &ServeOptions) -> i32 { println!("hu13: listening on {bound}"); use std::io::Write as _; let _ = std::io::stdout().flush(); - let server_salt = inbox::new_server_salt(); + let shared = std::sync::Arc::new(Shared { + www: opts.www.clone(), + inbox_dir: opts.inbox_dir.clone(), + salt: inbox::new_server_salt(), + limiter: inbox::RateLimiter::new(opts.rate_per_hour), + max_title: opts.max_title, + max_body: opts.max_body, + max_bytes: opts.max_bytes, + quota: opts.quota, + }); for conn in listener.incoming() { match conn { Ok(stream) => { - let www = opts.www.clone(); - let inbox_dir = opts.inbox_dir.clone(); - let salt = server_salt.clone(); - std::thread::spawn(move || handle_conn(stream, &www, &inbox_dir, &salt)); + let shared = std::sync::Arc::clone(&shared); + std::thread::spawn(move || handle_conn(stream, &shared)); } Err(e) => { eprintln!("hu13: accept: {e}"); @@ -215,6 +267,7 @@ fn reason(code: u16) -> &'static str { match code { 200 => "OK", 400 => "Bad Request", + 403 => "Forbidden", 404 => "Not Found", 405 => "Method Not Allowed", 413 => "Content Too Large", @@ -318,7 +371,72 @@ fn decode_path(s: &str) -> Option { String::from_utf8(bytes).ok() } -fn handle_conn(mut stream: TcpStream, www: &Path, inbox_dir: &Path, server_salt: &str) { +/// Hostname of an `Origin`/`Referer` value (`scheme://host[:port][/...]`) +/// or of a `Host` header (`host[:port]`), lowercased, brackets stripped. +/// `None` = unparsable (fail closed when it came from the browser). +fn host_of_url(value: &str) -> Option { + let v = value.trim(); + if (v.is_empty()) || v.eq_ignore_ascii_case("null") { + return None; + } + let after_scheme = match v.split_once("://") { + Some((_, rest)) => rest, + None => v, + }; + let hostport = after_scheme.split('/').next().unwrap_or(""); + let hostport = hostport.split('@').next_back().unwrap_or(""); + if hostport.is_empty() { + return None; + } + // Split port (last `:` with only digits after, or `[v6]:port`). + let host = if let Some(stripped) = hostport.strip_prefix('[') { + match stripped.split_once(']') { + Some((h, _)) => h, + None => return None, + } + } else if hostport.matches(':').count() == 1 { + match hostport.split_once(':') { + Some((h, port)) if !port.is_empty() && port.bytes().all(|b| b.is_ascii_digit()) => h, + Some(_) => return None, + None => hostport, + } + } else { + hostport + }; + let host = host.trim().trim_matches('.').to_ascii_lowercase(); + if host.is_empty() { + return None; + } + Some(host) +} + +/// Same-origin gate for the two POST routes: no `Origin` and no +/// `Referer` (curl/agents) → allow; browser-supplied `Origin` (else +/// `Referer`) must match the `Host` hostname → else `403`. Never echoes. +#[must_use] +fn origin_allowed(head: &Head) -> bool { + let origin = header(head, "origin"); + let referer = header(head, "referer"); + let claimed = match origin { + Some(o) if !o.trim().is_empty() => Some(o), + _ => match referer { + Some(r) if !r.trim().is_empty() => Some(r), + _ => None, + }, + }; + let Some(claimed) = claimed else { + return true; // curl sends none + }; + let (Some(want), Some(got)) = ( + header(head, "host").and_then(host_of_url), + host_of_url(claimed), + ) else { + return false; // unparsable Host or Origin → fail closed + }; + want == got +} + +fn handle_conn(mut stream: TcpStream, shared: &Shared) { let _ = stream.set_read_timeout(Some(Duration::from_secs(inbox::POST_READ_TIMEOUT_SECS))); let _ = stream.set_write_timeout(Some(Duration::from_secs(inbox::POST_READ_TIMEOUT_SECS))); let (head, rest) = match read_head(&mut stream) { @@ -339,7 +457,7 @@ fn handle_conn(mut stream: TcpStream, www: &Path, inbox_dir: &Path, server_salt: } else { inbox::InboxType::PrPrompt }; - handle_inbox(&mut stream, &head, rest, kind, inbox_dir, server_salt); + handle_inbox(&mut stream, &head, rest, kind, shared); return; } // Static: GET/HEAD only; never a listing (dirs → 404, files only). @@ -360,8 +478,8 @@ fn handle_conn(mut stream: TcpStream, www: &Path, inbox_dir: &Path, server_salt: } else { rel.to_string() }; - let dest = absolutize(&www.join(&file_rel)); - if dest != absolutize(www) && !dest.starts_with(absolutize(www)) { + let dest = absolutize(&shared.www.join(&file_rel)); + if dest != absolutize(&shared.www) && !dest.starts_with(absolutize(&shared.www)) { static_error(&mut stream, 404); return; } @@ -412,8 +530,7 @@ fn handle_inbox( head: &Head, rest: Vec, kind: inbox::InboxType, - inbox_dir: &Path, - server_salt: &str, + shared: &Shared, ) { // Inbox-route responses always carry `no-store` and always wait the // fixed ack delay (success and failure alike — no timing oracle). @@ -439,8 +556,14 @@ fn handle_inbox( answer(stream, 405, &body, &[("Allow", "POST")]); return; } - // TODO(m4-harden, issue #10): `Origin` check goes here (fail closed - // when a browser sends a mismatched Origin; curl sends none). + // Same-origin gate: browsers send Origin/Referer — mismatch fails + // closed; curl/agents send neither and pass. Generic page, no echo. + if !origin_allowed(head) { + settle(stream, rest.len(), content_length(head)); + let body = inbox::inbox_error_page("forbidden", "forbidden", "Bad origin."); + answer(stream, 403, &body, &[]); + return; + } let ctype = header(head, "content-type") .unwrap_or("") .to_ascii_lowercase(); @@ -462,10 +585,9 @@ fn handle_inbox( }, None => return, }; - if len > inbox::MAX_REQUEST_BYTES { + if len > shared.max_bytes { settle(stream, rest.len(), Some(len)); - let body = - inbox::inbox_error_page("too large", "too large", "Submissions are capped at 32KB."); + let body = inbox::inbox_error_page("too large", "too large", "Submission too large."); answer(stream, 413, &body, &[]); return; } @@ -510,7 +632,7 @@ fn handle_inbox( return; } }; - match inbox::validate_form(kind, &pairs) { + match inbox::validate_form_with_limits(kind, &pairs, shared.max_title, shared.max_body) { inbox::Validation::Honeypot => { // Silent `received` with a fresh (unstored) uuid — no oracle. let page = inbox::received_page(&inbox::new_uuid4()); @@ -521,24 +643,23 @@ fn handle_inbox( answer(stream, code, &body, &[]); } inbox::Validation::Accept(v) => { - // TODO(m4-harden, issue #10): stubs wired in — #10 fills them. let ip = stream .peer_addr() .map(|a| a.ip().to_string()) .unwrap_or_else(|_| "unknown".to_string()); - if !inbox::check_rate_limit(&ip) { + if !shared.limiter.check(&ip, &shared.salt) { let body = inbox::inbox_error_page("too many requests", "too many requests", "Slow down."); answer(stream, 429, &body, &[]); return; } - if !inbox::check_quota(inbox_dir) { + if !inbox::check_quota_with(&shared.inbox_dir, shared.quota) { let body = inbox::inbox_error_page("unavailable", "unavailable", "Inbox full. Try later."); answer(stream, 503, &body, &[]); return; } - match inbox::store_submission(inbox_dir, &v, &ip, server_salt) { + match inbox::store_submission(&shared.inbox_dir, &v, &ip, &shared.salt) { Ok(uuid) => { let page = inbox::received_page(&uuid); answer(stream, 200, &page, &[]); @@ -588,4 +709,50 @@ mod tests { assert!(decode_path("/a%ZZ").is_none(), "bad escape rejected"); assert!(decode_path("/plain/path-1_~x").is_some()); } + + fn head_with(host: &str, origin: Option<&str>, referer: Option<&str>) -> Head { + let mut headers = vec![("host".to_string(), host.to_string())]; + if let Some(o) = origin { + headers.push(("origin".to_string(), o.to_string())); + } + if let Some(r) = referer { + headers.push(("referer".to_string(), r.to_string())); + } + Head { + method: "POST".to_string(), + target: "/inbox/issue".to_string(), + headers, + } + } + + #[test] + fn origin_gate_allows_curl_blocks_forgery() { + assert!(origin_allowed(&head_with("127.0.0.1:1", None, None))); + assert!(origin_allowed(&head_with( + "127.0.0.1:39313", + Some("http://127.0.0.1:39313"), + None + ))); + assert!(!origin_allowed(&head_with( + "127.0.0.1:39313", + Some("https://evil.example"), + None + ))); + assert!(!origin_allowed(&head_with( + "127.0.0.1:1", + Some("null"), + None + ))); + // Referer fallback when Origin absent. + assert!(origin_allowed(&head_with( + "a.example", + None, + Some("http://a.example/page") + ))); + assert!(!origin_allowed(&head_with( + "a.example", + None, + Some("http://b.example/page") + ))); + } } diff --git a/tests/abuse.rs b/tests/abuse.rs new file mode 100644 index 0000000..9cca7d0 --- /dev/null +++ b/tests/abuse.rs @@ -0,0 +1,507 @@ +//! Inbox abuse suite (m4-harden, f0rge issue #10): adversarial checks. +//! +//! Each test spawns the real binary (`hu13 serve --listen 127.0.0.1:0`) +//! and talks raw HTTP over `TcpStream`. Suite must run in <60s: few +//! POSTs per test (each costs the fixed ~500ms ack delay by design). + +use std::collections::HashMap; +use std::io::{BufRead, BufReader, Read, Write}; +use std::net::TcpStream; +use std::path::{Path, PathBuf}; +use std::process::{Child, Command, Stdio}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::{Duration, Instant}; + +static COUNTER: AtomicU64 = AtomicU64::new(0); + +fn unique(prefix: &str) -> PathBuf { + let id = COUNTER.fetch_add(1, Ordering::SeqCst); + std::env::temp_dir().join(format!( + "hu13-abuse-{prefix}-{}-{id}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0) + )) +} + +struct TmpDir(PathBuf); +impl TmpDir { + fn fresh(prefix: &str) -> Self { + let p = unique(prefix); + std::fs::create_dir_all(&p).unwrap(); + Self(p) + } + fn path(&self) -> &Path { + &self.0 + } +} +impl Drop for TmpDir { + fn drop(&mut self) { + std::fs::remove_dir_all(&self.0).ok(); + } +} + +fn bin() -> &'static str { + env!("CARGO_BIN_EXE_hu13") +} + +struct Server { + child: Child, + addr: String, + _www: Option, + _inbox_owned: Option, + inbox_path: PathBuf, +} +impl Server { + fn inbox_files(&self) -> Vec { + let mut out: Vec = std::fs::read_dir(&self.inbox_path) + .map(|rd| { + rd.flatten() + .map(|e| e.path()) + .filter(|p| p.is_file()) + .collect() + }) + .unwrap_or_default(); + out.sort(); + out + } +} +impl Drop for Server { + fn drop(&mut self) { + self.child.kill().ok(); + self.child.wait().ok(); + } +} + +fn start_with( + www: TmpDir, + inbox: Option, + inbox_path: PathBuf, + extra_args: &[&str], + envs: &HashMap, +) -> (Server, TmpDir) { + let mut cmd = Command::new(bin()); + cmd.arg("serve") + .arg("--www") + .arg(www.path()) + .arg("--inbox-dir") + .arg(&inbox_path) + .arg("--listen") + .arg("127.0.0.1:0"); + for a in extra_args { + cmd.arg(a); + } + for (k, v) in envs { + cmd.env(k, v); + } + cmd.stdout(Stdio::piped()).stderr(Stdio::piped()); + let mut child = cmd.spawn().expect("spawn hu13 serve"); + let stdout = child.stdout.take().expect("piped stdout"); + let mut lines = BufReader::new(stdout).lines(); + let mut addr = String::new(); + for _ in 0..50 { + match lines.next() { + Some(Ok(line)) if line.contains("listening on ") => { + addr = line + .rsplit("listening on ") + .next() + .unwrap_or("") + .trim() + .to_string(); + break; + } + Some(Ok(_)) => continue, + _ => break, + } + } + assert!(!addr.is_empty(), "serve printed no `listening on` line"); + assert!(addr.starts_with("127.0.0.1:"), "loopback only, got {addr}"); + std::thread::sleep(Duration::from_millis(100)); + let srv = Server { + child, + addr, + _www: Some(www), + _inbox_owned: inbox, + inbox_path, + }; + // Return a dummy www holder? Caller keeps server alive; second value unused. + (srv, TmpDir::fresh("unused")) +} + +fn start_server(extra_args: &[&str], envs: &HashMap) -> Server { + let www = TmpDir::fresh("www"); + std::fs::write( + www.path().join("index.html"), + "hi

hi

", + ) + .unwrap(); + let inbox = TmpDir::fresh("inbox"); + let inbox_path = inbox.path().to_path_buf(); + let (srv, _dummy) = start_with(www, Some(inbox), inbox_path, extra_args, envs); + srv +} + +fn no_env() -> HashMap { + HashMap::new() +} + +fn transact(addr: &str, req: &[u8]) -> String { + let mut s = TcpStream::connect(addr).expect("connect"); + s.set_read_timeout(Some(Duration::from_secs(10))).unwrap(); + s.set_write_timeout(Some(Duration::from_secs(10))).unwrap(); + s.write_all(req).expect("write"); + let mut out = Vec::new(); + s.read_to_end(&mut out).expect("read"); + String::from_utf8_lossy(&out).into_owned() +} + +fn status_line(resp: &str) -> &str { + resp.split("\r\n").next().unwrap_or("") +} + +fn resp_body(resp: &str) -> &str { + match resp.find("\r\n\r\n") { + Some(i) => &resp[i + 4..], + None => "", + } +} + +fn header_value(resp: &str, name: &str) -> Option { + let want = name.to_ascii_lowercase(); + for line in resp.split("\r\n").skip(1) { + if line.is_empty() { + break; + } + if let Some((k, v)) = line.split_once(':') { + if k.trim().to_ascii_lowercase() == want { + return Some(v.trim().to_string()); + } + } + } + None +} + +fn assert_post_hygiene(resp: &str) { + let nosniff = header_value(resp, "x-content-type-options").unwrap_or_default(); + assert_eq!( + nosniff.to_ascii_lowercase(), + "nosniff", + "POST responses carry nosniff, got: {resp}" + ); + let cc = header_value(resp, "cache-control").unwrap_or_default(); + assert!( + cc.to_ascii_lowercase().contains("no-store"), + "POST responses carry no-store, got: {resp}" + ); + assert!( + header_value(resp, "set-cookie").is_none(), + "no cookies ever, got: {resp}" + ); + assert!( + header_value(resp, "access-control-allow-origin").is_none(), + "no CORS ever, got: {resp}" + ); +} + +fn enc(s: &str) -> String { + let mut o = String::new(); + for b in s.bytes() { + match b { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { + o.push(b as char); + } + b' ' => o.push('+'), + _ => o.push_str(&format!("%{b:02X}")), + } + } + o +} + +fn post(path: &str, ctype: &str, body: &[u8], extra_headers: &[(&str, &str)]) -> Vec { + let mut head = format!( + "POST {path} HTTP/1.1\r\nHost: x\r\nContent-Type: {ctype}\r\nContent-Length: {}\r\n", + body.len() + ); + for (k, v) in extra_headers { + head.push_str(k); + head.push_str(": "); + head.push_str(v); + head.push_str("\r\n"); + } + head.push_str("Connection: close\r\n\r\n"); + head.into_bytes() + .into_iter() + .chain(body.iter().copied()) + .collect() +} + +const URLENCODED: &str = "application/x-www-form-urlencoded"; + +fn valid_issue(marker: &str) -> Vec { + format!( + "title={}&body={}&license=agree&website=", + enc(&format!("t {marker}")), + enc(&format!("b {marker}")), + ) + .into_bytes() +} + +// ---- abuse: one behavior each ------------------------------------------ + +#[test] +fn three_rapid_posts_third_is_429() { + let srv = start_server(&[], &no_env()); + let mut codes = Vec::new(); + let mut last = String::new(); + let t0 = Instant::now(); + for i in 0..3 { + let r = transact( + &srv.addr, + &post( + "/inbox/issue", + URLENCODED, + &valid_issue(&format!("RATE-{i}")), + &[], + ), + ); + codes.push(status_line(&r).to_string()); + last = r; + } + let elapsed = t0.elapsed(); + assert!(codes[0].contains("200"), "1st: {codes:?}"); + assert!(codes[1].contains("200"), "2nd: {codes:?}"); + assert!(codes[2].contains("429"), "3rd is 429: {codes:?}"); + assert_post_hygiene(&last); + assert!( + elapsed >= Duration::from_millis(3 * 350), + "fixed delay on every POST incl. 429 (took {elapsed:?})" + ); + assert_eq!(srv.inbox_files().len(), 2, "429 stores nothing new"); +} + +#[test] +fn quota_full_tiny_quota_is_503() { + // Isolate quota from rate: generous bucket, tiny quota via flags. + let srv = start_server(&["--quota", "2", "--rate-per-hour", "100"], &no_env()); + for i in 0..2 { + let r = transact( + &srv.addr, + &post( + "/inbox/issue", + URLENCODED, + &valid_issue(&format!("QUOTA-{i}")), + &[], + ), + ); + assert!(status_line(&r).contains("200"), "fill {i}: {r}"); + } + assert_eq!(srv.inbox_files().len(), 2); + let r = transact( + &srv.addr, + &post("/inbox/issue", URLENCODED, &valid_issue("QUOTA-FULL"), &[]), + ); + assert!( + status_line(&r).contains("503"), + "quota-full is 503, got: {r}" + ); + assert_post_hygiene(&r); + assert_eq!(srv.inbox_files().len(), 2, "503 stores nothing"); +} + +#[test] +fn oversize_is_413() { + let srv = start_server(&[], &no_env()); + let big = "b".repeat(33 * 1024); + let body = format!( + "title={}&body={}&license=agree&website=", + enc("t"), + enc(&big) + ); + assert!(body.len() > 32 * 1024); + let r = transact( + &srv.addr, + &post("/inbox/issue", URLENCODED, body.as_bytes(), &[]), + ); + assert!( + status_line(&r).contains("413"), + "got: {}", + &r[..r.len().min(200)] + ); + assert_post_hygiene(&r); + assert!(srv.inbox_files().is_empty()); +} + +#[test] +fn multipart_is_400() { + let srv = start_server(&[], &no_env()); + let body = "--xyz\r\nContent-Disposition: form-data; name=\"title\"\r\n\r\nt\r\n--xyz--\r\n"; + let r = transact( + &srv.addr, + &post( + "/inbox/issue", + "multipart/form-data; boundary=xyz", + body.as_bytes(), + &[], + ), + ); + assert!(status_line(&r).contains("400"), "got: {r}"); + assert_post_hygiene(&r); + assert!(srv.inbox_files().is_empty()); +} + +#[test] +fn honeypot_silent_zero_files() { + let srv = start_server(&[], &no_env()); + let body = format!( + "title={}&body={}&license=agree&website=bot-probe", + enc("t"), + enc("b"), + ); + let r = transact( + &srv.addr, + &post("/inbox/issue", URLENCODED, body.as_bytes(), &[]), + ); + assert!(status_line(&r).contains("200"), "fake received, got: {r}"); + assert!(resp_body(&r).contains("received")); + assert_post_hygiene(&r); + assert!(!r.contains("bot-probe"), "no reflection even on drop"); + assert!(srv.inbox_files().is_empty(), "honeypot stores nothing"); +} + +#[test] +fn null_byte_and_non_utf8_are_400() { + let srv = start_server(&[], &no_env()); + let r1 = transact( + &srv.addr, + &post( + "/inbox/issue", + URLENCODED, + b"title=a%00b&body=c&license=agree&website=", + &[], + ), + ); + assert!(status_line(&r1).contains("400"), "nul: {r1}"); + assert_post_hygiene(&r1); + let mut bad = b"title=t&body=".to_vec(); + bad.extend_from_slice(&[0xff, 0xfe]); + bad.extend_from_slice(b"&license=agree&website="); + let r2 = transact(&srv.addr, &post("/inbox/issue", URLENCODED, &bad, &[])); + assert!(status_line(&r2).contains("400"), "non-utf8: {r2}"); + assert_post_hygiene(&r2); + assert!(srv.inbox_files().is_empty()); +} + +#[test] +fn reflected_xss_probe_contains_no_input_bytes() { + let srv = start_server(&[], &no_env()); + let marker = format!("XSS-{}-{}", std::process::id(), unique("x").display()); + let evil = format!(" {marker}"); + let body = format!( + "title={}&body={}&license=agree&website=", + enc(&evil), + enc(&evil), + ); + let r = transact( + &srv.addr, + &post("/inbox/issue", URLENCODED, body.as_bytes(), &[]), + ); + assert!(status_line(&r).contains("200"), "got: {r}"); + assert_post_hygiene(&r); + assert!( + !r.contains(&marker), + "response contains no input bytes (reflected-XSS dead)" + ); + assert!(!r.contains("hi

").unwrap(); + let inbox_dir = TmpDir::fresh("shared-inbox"); + let inbox_path = inbox_dir.path().to_path_buf(); + let marker = format!("PERSIST-{}", std::process::id()); + let first = { + let (srv, _d) = start_with(www1, None, inbox_path.clone(), &[], &no_env()); + let r = transact( + &srv.addr, + &post("/inbox/issue", URLENCODED, &valid_issue(&marker), &[]), + ); + assert!(status_line(&r).contains("200"), "first store: {r}"); + assert_eq!(srv.inbox_files().len(), 1); + std::fs::read_to_string(&srv.inbox_files()[0]).unwrap() + // srv drops here → first serve instance exits + }; + assert!(first.contains(&marker)); + // Second serve instance sharing the same inbox dir: file survives. + let www2 = TmpDir::fresh("www2"); + std::fs::write(www2.path().join("index.html"), "

hi

").unwrap(); + let (srv2, _d2) = start_with(www2, None, inbox_path.clone(), &[], &no_env()); + let files = srv2.inbox_files(); + assert_eq!(files.len(), 1, "files survive serve restart"); + assert_eq!(std::fs::read_to_string(&files[0]).unwrap(), first); + // Fresh rate buckets after restart (unlinkable): a new POST still lands. + let r = transact( + &srv2.addr, + &post("/inbox/issue", URLENCODED, &valid_issue("PERSIST-2"), &[]), + ); + assert!(status_line(&r).contains("200"), "post-restart store: {r}"); + assert_eq!(srv2.inbox_files().len(), 2); + // `inbox_path` lives in `inbox_dir` (kept alive by the test); servers + // hold no ownership (`None`) so drop order is safe. + let _ = &inbox_dir; +} + +#[test] +fn origin_forgery_is_403_without_origin_passes() { + let srv = start_server(&[], &no_env()); + let evil = transact( + &srv.addr, + &post( + "/inbox/issue", + URLENCODED, + &valid_issue("ORIGIN-EVIL"), + &[("Origin", "https://evil.example"), ("Host", "x")], + ), + ); + assert!(status_line(&evil).contains("403"), "forgery: {evil}"); + assert_post_hygiene(&evil); + assert!(!evil.contains("evil.example"), "no echo of Origin"); + assert!(srv.inbox_files().is_empty()); + let ok = transact( + &srv.addr, + &post("/inbox/issue", URLENCODED, &valid_issue("ORIGIN-OK"), &[]), + ); + assert!( + status_line(&ok).contains("200"), + "curl (no Origin) passes: {ok}" + ); +} + +#[test] +fn caps_tunable_via_env() { + // HU13_MAX_TITLE=5 via env: a 6-char title must fail where default passes. + let mut env = HashMap::new(); + env.insert("HU13_MAX_TITLE".to_string(), "5".to_string()); + let srv = start_server(&[], &env); + let body = format!( + "title={}&body={}&license=agree&website=", + enc("123456"), + enc("b") + ); + let r = transact( + &srv.addr, + &post("/inbox/issue", URLENCODED, body.as_bytes(), &[]), + ); + assert!( + status_line(&r).contains("400"), + "tuned cap enforced, got: {r}" + ); + assert!(srv.inbox_files().is_empty()); +} -- 2.55.0