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 }