tests/abuse.rs 16.7 KiB 555 lines Raw
1 //! Inbox abuse suite (m4-harden, f0rge issue #10): adversarial checks.2 //!3 //! Each test spawns the real binary (`hu13 serve --listen 127.0.0.1:0`)4 //! and talks raw HTTP over `TcpStream`. Suite must run in <60s: few5 //! POSTs per test (each costs the fixed ~500ms ack delay by design).6 7 use std::collections::HashMap;8 use std::io::{BufRead, BufReader, Read, Write};9 use std::net::TcpStream;10 use std::path::{Path, PathBuf};11 use std::process::{Child, Command, Stdio};12 use std::sync::atomic::{AtomicU64, Ordering};13 use std::time::{Duration, Instant};14 15 static COUNTER: AtomicU64 = AtomicU64::new(0);16 17 fn unique(prefix: &str) -> PathBuf {18     let id = COUNTER.fetch_add(1, Ordering::SeqCst);19     std::env::temp_dir().join(format!(20         "hu13-abuse-{prefix}-{}-{id}-{}",21         std::process::id(),22         std::time::SystemTime::now()23             .duration_since(std::time::UNIX_EPOCH)24             .map(|d| d.as_nanos())25             .unwrap_or(0)26     ))27 }28 29 struct TmpDir(PathBuf);30 impl TmpDir {31     fn fresh(prefix: &str) -> Self {32         let p = unique(prefix);33         std::fs::create_dir_all(&p).unwrap();34         Self(p)35     }36     fn path(&self) -> &Path {37         &self.038     }39 }40 impl Drop for TmpDir {41     fn drop(&mut self) {42         std::fs::remove_dir_all(&self.0).ok();43     }44 }45 46 fn bin() -> &'static str {47     env!("CARGO_BIN_EXE_hu13")48 }49 50 struct Server {51     child: Child,52     addr: String,53     _www: Option<TmpDir>,54     _inbox_owned: Option<TmpDir>,55     inbox_path: PathBuf,56 }57 impl Server {58     fn inbox_files(&self) -> Vec<PathBuf> {59         let mut out: Vec<PathBuf> = std::fs::read_dir(&self.inbox_path)60             .map(|rd| {61                 rd.flatten()62                     .map(|e| e.path())63                     .filter(|p| p.is_file())64                     .collect()65             })66             .unwrap_or_default();67         out.sort();68         out69     }70 }71 impl Drop for Server {72     fn drop(&mut self) {73         self.child.kill().ok();74         self.child.wait().ok();75     }76 }77 78 fn start_with(79     www: TmpDir,80     inbox: Option<TmpDir>,81     inbox_path: PathBuf,82     extra_args: &[&str],83     envs: &HashMap<String, String>,84 ) -> (Server, TmpDir) {85     let mut cmd = Command::new(bin());86     cmd.arg("serve")87         .arg("--www")88         .arg(www.path())89         .arg("--inbox-dir")90         .arg(&inbox_path)91         .arg("--listen")92         .arg("127.0.0.1:0");93     for a in extra_args {94         cmd.arg(a);95     }96     for (k, v) in envs {97         cmd.env(k, v);98     }99     cmd.stdout(Stdio::piped()).stderr(Stdio::piped());100     let mut child = cmd.spawn().expect("spawn hu13 serve");101     let stdout = child.stdout.take().expect("piped stdout");102     let mut lines = BufReader::new(stdout).lines();103     let mut addr = String::new();104     for _ in 0..50 {105         match lines.next() {106             Some(Ok(line)) if line.contains("listening on ") => {107                 addr = line108                     .rsplit("listening on ")109                     .next()110                     .unwrap_or("")111                     .trim()112                     .to_string();113                 break;114             }115             Some(Ok(_)) => continue,116             _ => break,117         }118     }119     assert!(!addr.is_empty(), "serve printed no `listening on` line");120     assert!(addr.starts_with("127.0.0.1:"), "loopback only, got {addr}");121     std::thread::sleep(Duration::from_millis(100));122     let srv = Server {123         child,124         addr,125         _www: Some(www),126         _inbox_owned: inbox,127         inbox_path,128     };129     // Return a dummy www holder? Caller keeps server alive; second value unused.130     (srv, TmpDir::fresh("unused"))131 }132 133 fn start_server(extra_args: &[&str], envs: &HashMap<String, String>) -> Server {134     let www = TmpDir::fresh("www");135     std::fs::write(136         www.path().join("index.html"),137         "<!DOCTYPE html><title>hi</title><p>hi</p>",138     )139     .unwrap();140     let inbox = TmpDir::fresh("inbox");141     let inbox_path = inbox.path().to_path_buf();142     let (srv, _dummy) = start_with(www, Some(inbox), inbox_path, extra_args, envs);143     srv144 }145 146 fn no_env() -> HashMap<String, String> {147     HashMap::new()148 }149 150 fn transact(addr: &str, req: &[u8]) -> String {151     let mut s = TcpStream::connect(addr).expect("connect");152     s.set_read_timeout(Some(Duration::from_secs(10))).unwrap();153     s.set_write_timeout(Some(Duration::from_secs(10))).unwrap();154     s.write_all(req).expect("write");155     let mut out = Vec::new();156     s.read_to_end(&mut out).expect("read");157     String::from_utf8_lossy(&out).into_owned()158 }159 160 fn status_line(resp: &str) -> &str {161     resp.split("\r\n").next().unwrap_or("")162 }163 164 fn resp_body(resp: &str) -> &str {165     match resp.find("\r\n\r\n") {166         Some(i) => &resp[i + 4..],167         None => "",168     }169 }170 171 fn header_value(resp: &str, name: &str) -> Option<String> {172     let want = name.to_ascii_lowercase();173     for line in resp.split("\r\n").skip(1) {174         if line.is_empty() {175             break;176         }177         if let Some((k, v)) = line.split_once(':') {178             if k.trim().to_ascii_lowercase() == want {179                 return Some(v.trim().to_string());180             }181         }182     }183     None184 }185 186 fn assert_post_hygiene(resp: &str) {187     let nosniff = header_value(resp, "x-content-type-options").unwrap_or_default();188     assert_eq!(189         nosniff.to_ascii_lowercase(),190         "nosniff",191         "POST responses carry nosniff, got: {resp}"192     );193     let cc = header_value(resp, "cache-control").unwrap_or_default();194     assert!(195         cc.to_ascii_lowercase().contains("no-store"),196         "POST responses carry no-store, got: {resp}"197     );198     assert!(199         header_value(resp, "set-cookie").is_none(),200         "no cookies ever, got: {resp}"201     );202     assert!(203         header_value(resp, "access-control-allow-origin").is_none(),204         "no CORS ever, got: {resp}"205     );206 }207 208 fn enc(s: &str) -> String {209     let mut o = String::new();210     for b in s.bytes() {211         match b {212             b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {213                 o.push(b as char);214             }215             b' ' => o.push('+'),216             _ => o.push_str(&format!("%{b:02X}")),217         }218     }219     o220 }221 222 fn post(path: &str, ctype: &str, body: &[u8], extra_headers: &[(&str, &str)]) -> Vec<u8> {223     let mut head = format!(224         "POST {path} HTTP/1.1\r\nHost: x\r\nContent-Type: {ctype}\r\nContent-Length: {}\r\n",225         body.len()226     );227     for (k, v) in extra_headers {228         head.push_str(k);229         head.push_str(": ");230         head.push_str(v);231         head.push_str("\r\n");232     }233     head.push_str("Connection: close\r\n\r\n");234     head.into_bytes()235         .into_iter()236         .chain(body.iter().copied())237         .collect()238 }239 240 const URLENCODED: &str = "application/x-www-form-urlencoded";241 242 fn valid_issue(marker: &str) -> Vec<u8> {243     format!(244         "title={}&body={}&license=agree&website=",245         enc(&format!("t {marker}")),246         enc(&format!("b {marker}")),247     )248     .into_bytes()249 }250 251 // ---- abuse: one behavior each ------------------------------------------252 253 #[test]254 fn three_rapid_posts_third_is_429() {255     let srv = start_server(&[], &no_env());256     let mut codes = Vec::new();257     let mut last = String::new();258     let t0 = Instant::now();259     for i in 0..3 {260         let r = transact(261             &srv.addr,262             &post(263                 "/inbox/issue",264                 URLENCODED,265                 &valid_issue(&format!("RATE-{i}")),266                 &[],267             ),268         );269         codes.push(status_line(&r).to_string());270         last = r;271     }272     let elapsed = t0.elapsed();273     assert!(codes[0].contains("200"), "1st: {codes:?}");274     assert!(codes[1].contains("200"), "2nd: {codes:?}");275     assert!(codes[2].contains("429"), "3rd is 429: {codes:?}");276     assert_post_hygiene(&last);277     assert!(278         elapsed >= Duration::from_millis(3 * 350),279         "fixed delay on every POST incl. 429 (took {elapsed:?})"280     );281     assert_eq!(srv.inbox_files().len(), 2, "429 stores nothing new");282 }283 284 #[test]285 fn quota_full_tiny_quota_is_503() {286     // Isolate quota from rate: generous bucket, tiny quota via flags.287     let srv = start_server(&["--quota", "2", "--rate-per-hour", "100"], &no_env());288     for i in 0..2 {289         let r = transact(290             &srv.addr,291             &post(292                 "/inbox/issue",293                 URLENCODED,294                 &valid_issue(&format!("QUOTA-{i}")),295                 &[],296             ),297         );298         assert!(status_line(&r).contains("200"), "fill {i}: {r}");299     }300     assert_eq!(srv.inbox_files().len(), 2);301     let r = transact(302         &srv.addr,303         &post("/inbox/issue", URLENCODED, &valid_issue("QUOTA-FULL"), &[]),304     );305     assert!(306         status_line(&r).contains("503"),307         "quota-full is 503, got: {r}"308     );309     assert_post_hygiene(&r);310     assert_eq!(srv.inbox_files().len(), 2, "503 stores nothing");311 }312 313 #[test]314 fn oversize_is_413() {315     let srv = start_server(&[], &no_env());316     let big = "b".repeat(33 * 1024);317     let body = format!(318         "title={}&body={}&license=agree&website=",319         enc("t"),320         enc(&big)321     );322     assert!(body.len() > 32 * 1024);323     let r = transact(324         &srv.addr,325         &post("/inbox/issue", URLENCODED, body.as_bytes(), &[]),326     );327     assert!(328         status_line(&r).contains("413"),329         "got: {}",330         &r[..r.len().min(200)]331     );332     assert_post_hygiene(&r);333     assert!(srv.inbox_files().is_empty());334 }335 336 #[test]337 fn multipart_is_400() {338     let srv = start_server(&[], &no_env());339     let body = "--xyz\r\nContent-Disposition: form-data; name=\"title\"\r\n\r\nt\r\n--xyz--\r\n";340     let r = transact(341         &srv.addr,342         &post(343             "/inbox/issue",344             "multipart/form-data; boundary=xyz",345             body.as_bytes(),346             &[],347         ),348     );349     assert!(status_line(&r).contains("400"), "got: {r}");350     assert_post_hygiene(&r);351     assert!(srv.inbox_files().is_empty());352 }353 354 #[test]355 fn honeypot_silent_zero_files() {356     let srv = start_server(&[], &no_env());357     let body = format!(358         "title={}&body={}&license=agree&website=bot-probe",359         enc("t"),360         enc("b"),361     );362     let r = transact(363         &srv.addr,364         &post("/inbox/issue", URLENCODED, body.as_bytes(), &[]),365     );366     assert!(status_line(&r).contains("200"), "fake received, got: {r}");367     assert!(resp_body(&r).contains("received"));368     assert_post_hygiene(&r);369     assert!(!r.contains("bot-probe"), "no reflection even on drop");370     assert!(srv.inbox_files().is_empty(), "honeypot stores nothing");371 }372 373 #[test]374 fn null_byte_and_non_utf8_are_400() {375     let srv = start_server(&[], &no_env());376     let r1 = transact(377         &srv.addr,378         &post(379             "/inbox/issue",380             URLENCODED,381             b"title=a%00b&body=c&license=agree&website=",382             &[],383         ),384     );385     assert!(status_line(&r1).contains("400"), "nul: {r1}");386     assert_post_hygiene(&r1);387     let mut bad = b"title=t&body=".to_vec();388     bad.extend_from_slice(&[0xff, 0xfe]);389     bad.extend_from_slice(b"&license=agree&website=");390     let r2 = transact(&srv.addr, &post("/inbox/issue", URLENCODED, &bad, &[]));391     assert!(status_line(&r2).contains("400"), "non-utf8: {r2}");392     assert_post_hygiene(&r2);393     assert!(srv.inbox_files().is_empty());394 }395 396 #[test]397 fn reflected_xss_probe_contains_no_input_bytes() {398     let srv = start_server(&[], &no_env());399     let marker = format!("XSS-{}-{}", std::process::id(), unique("x").display());400     let evil = format!("<script>alert(1)</script><img src=x onerror=alert(2)> {marker}");401     let body = format!(402         "title={}&body={}&license=agree&website=",403         enc(&evil),404         enc(&evil),405     );406     let r = transact(407         &srv.addr,408         &post("/inbox/issue", URLENCODED, body.as_bytes(), &[]),409     );410     assert!(status_line(&r).contains("200"), "got: {r}");411     assert_post_hygiene(&r);412     assert!(413         !r.contains(&marker),414         "response contains no input bytes (reflected-XSS dead)"415     );416     assert!(!r.contains("<script"), "zero reflection, zero JS");417     // Stored raw (never rendered to HTML over HTTP); file itself is fine.418     assert_eq!(srv.inbox_files().len(), 1);419     let raw = std::fs::read_to_string(&srv.inbox_files()[0]).unwrap();420     assert!(!raw.contains("127.0.0.1"), "no dotted quad on disk");421 }422 423 #[test]424 fn restart_persistence_files_survive() {425     let www1 = TmpDir::fresh("www1");426     std::fs::write(www1.path().join("index.html"), "<p>hi</p>").unwrap();427     let inbox_dir = TmpDir::fresh("shared-inbox");428     let inbox_path = inbox_dir.path().to_path_buf();429     let marker = format!("PERSIST-{}", std::process::id());430     let first = {431         let (srv, _d) = start_with(www1, None, inbox_path.clone(), &[], &no_env());432         let r = transact(433             &srv.addr,434             &post("/inbox/issue", URLENCODED, &valid_issue(&marker), &[]),435         );436         assert!(status_line(&r).contains("200"), "first store: {r}");437         assert_eq!(srv.inbox_files().len(), 1);438         std::fs::read_to_string(&srv.inbox_files()[0]).unwrap()439         // srv drops here → first serve instance exits440     };441     assert!(first.contains(&marker));442     // Second serve instance sharing the same inbox dir: file survives.443     let www2 = TmpDir::fresh("www2");444     std::fs::write(www2.path().join("index.html"), "<p>hi</p>").unwrap();445     let (srv2, _d2) = start_with(www2, None, inbox_path.clone(), &[], &no_env());446     let files = srv2.inbox_files();447     assert_eq!(files.len(), 1, "files survive serve restart");448     assert_eq!(std::fs::read_to_string(&files[0]).unwrap(), first);449     // Fresh rate buckets after restart (unlinkable): a new POST still lands.450     let r = transact(451         &srv2.addr,452         &post("/inbox/issue", URLENCODED, &valid_issue("PERSIST-2"), &[]),453     );454     assert!(status_line(&r).contains("200"), "post-restart store: {r}");455     assert_eq!(srv2.inbox_files().len(), 2);456     // `inbox_path` lives in `inbox_dir` (kept alive by the test); servers457     // hold no ownership (`None`) so drop order is safe.458     let _ = &inbox_dir;459 }460 461 #[test]462 fn origin_forgery_is_403_without_origin_passes() {463     let srv = start_server(&[], &no_env());464     let evil = transact(465         &srv.addr,466         &post(467             "/inbox/issue",468             URLENCODED,469             &valid_issue("ORIGIN-EVIL"),470             &[("Origin", "https://evil.example"), ("Host", "x")],471         ),472     );473     assert!(status_line(&evil).contains("403"), "forgery: {evil}");474     assert_post_hygiene(&evil);475     assert!(!evil.contains("evil.example"), "no echo of Origin");476     assert!(srv.inbox_files().is_empty());477     let ok = transact(478         &srv.addr,479         &post("/inbox/issue", URLENCODED, &valid_issue("ORIGIN-OK"), &[]),480     );481     assert!(482         status_line(&ok).contains("200"),483         "curl (no Origin) passes: {ok}"484     );485 }486 487 #[test]488 fn caps_tunable_via_env() {489     // HU13_MAX_TITLE=5 via env: a 6-char title must fail where default passes.490     let mut env = HashMap::new();491     env.insert("HU13_MAX_TITLE".to_string(), "5".to_string());492     let srv = start_server(&[], &env);493     let body = format!(494         "title={}&body={}&license=agree&website=",495         enc("123456"),496         enc("b")497     );498     let r = transact(499         &srv.addr,500         &post("/inbox/issue", URLENCODED, body.as_bytes(), &[]),501     );502     assert!(503         status_line(&r).contains("400"),504         "tuned cap enforced, got: {r}"505     );506     assert!(srv.inbox_files().is_empty());507 }508 509 #[test]510 fn connection_cap_answers_503_instead_of_spawning_more_threads() {511     // f0rge #63: MAX_CONNS=64 threads, each parked in read_head. The512     // 65th connection must get an immediate 503, not another thread.513     let srv = start_server(&[], &no_env());514     let mut idle = Vec::new();515     for _ in 0..64 {516         match TcpStream::connect(&srv.addr) {517             Ok(s) => idle.push(s),518             Err(_) => break,519         }520     }521     assert_eq!(idle.len(), 64, "opened the full pool of idle sockets");522     // Let the accept loop park all 64 worker threads on read_head.523     std::thread::sleep(Duration::from_millis(300));524 525     let start = Instant::now();526     let resp = transact(527         &srv.addr,528         b"GET / HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n",529     );530     let took = start.elapsed();531     assert!(532         resp.starts_with("HTTP/1.1 503"),533         "overloaded server answers 503, got: {resp}"534     );535     assert!(536         took < Duration::from_secs(2),537         "503 is immediate (no ack sleep), took {took:?}"538     );539     assert!(540         resp.contains("Retry-After: 1"),541         "retry hint present: {resp}"542     );543 544     // Dropping the idle sockets frees slots: the next request succeeds.545     drop(idle);546     std::thread::sleep(Duration::from_millis(300));547     let resp = transact(548         &srv.addr,549         b"GET / HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n",550     );551     assert!(552         resp.starts_with("HTTP/1.1 200"),553         "capacity is released when connections close, got: {resp}"554     );555 }