#10 m4/inbox-harden
Patch
src/inbox.rs+136 −30--- a/src/inbox.rs+++ b/src/inbox.rs@@ -36,37 +36,74 @@ pub const ACK_DELAY_MS: u64 = 500;3636 /// Stored `license_version` for every accepted submission.3737 pub const LICENSE_VERSION: &str = "v1";3838 39-// TODO(m4-harden, issue #10): abuse controls live here. Stubs below are40-// wired into the POST path (they currently allow everything) so #10 only41-// has to fill in bodies + flags/env tunables, no call-site surgery.42-43-/// TODO(m4-harden, issue #10): per-IP bucket (2/hr) — wire flags44-/// `--rate-per-hour` / `HU13_RATE_PER_HOUR` here.39+/// Abuse controls (m4-harden, issue #10): per-IP token bucket + global40+/// quota + tunables. Flags/env only, no config file (see `hu13 serve41+/// --help`).42+/// Per-IP bucket capacity per hour (default; `--rate-per-hour` /43+/// `HU13_RATE_PER_HOUR` overrides per serve run).4544 pub const RATE_PER_HOUR: u32 = 2;46-/// TODO(m4-harden, issue #10): global quota (files) — wire flags47-/// `--quota` / `HU13_QUOTA` here.45+/// Global quota default (files; `--quota` / `HU13_QUOTA` overrides).4846 pub const QUOTA_FILES: usize = 500;4947 50-/// TODO(m4-harden, issue #10): per-IP token bucket. Currently always51-/// allows; #10 implements the bucket + `429` path (already wired).52-#[must_use]53-pub fn check_rate_limit(_ip: &str) -> bool {54- let _ = RATE_PER_HOUR;55- true48+/// Per-IP token bucket (sliding 1h window, capacity = `limit_per_hour`).49+/// Keys are daily-salted hashes only (`salted_ip_hash(ip, date, salt)`)50+/// — plaintext IPs never touch disk or logs; memory holds hashes +51+/// timestamps. A per-startup random `server_salt` keeps restarts52+/// unlinkable; the `yyyy-mm-dd` date rotates the key daily.53+/// `429` when the bucket is empty.54+pub struct RateLimiter {55+ limit_per_hour: u32,56+ buckets: std::sync::Mutex<std::collections::HashMap<String, Vec<u64>>>,5657 }5758 58-/// TODO(m4-harden, issue #10): global quota (`503` when full until drained59-/// by hand). Currently always allows; #10 implements the count + `503`60-/// path (already wired).61-#[must_use]62-pub fn check_quota(_inbox_dir: &Path) -> bool {63- let _ = QUOTA_FILES;64- true59+impl RateLimiter {60+ #[must_use]61+ pub fn new(limit_per_hour: u32) -> Self {62+ Self {63+ limit_per_hour,64+ buckets: std::sync::Mutex::new(std::collections::HashMap::new()),65+ }66+ }67+68+ fn check_at(&self, ip: &str, date: &str, server_salt: &str, now: u64) -> bool {69+ let key = salted_ip_hash(ip, date, server_salt);70+ let mut map = self.buckets.lock().unwrap_or_else(|e| e.into_inner());71+ let entry = map.entry(key).or_default();72+ entry.retain(|&t| now.wrapping_sub(t) < 3600);73+ if (entry.len() as u32) >= self.limit_per_hour {74+ return false;75+ }76+ entry.push(now);77+ true78+ }79+80+ /// Record one accepted POST from `ip`; `false` = over budget (`429`).81+ #[must_use]82+ pub fn check(&self, ip: &str, server_salt: &str) -> bool {83+ self.check_at(ip, &utc_today(), server_salt, unix_secs())84+ }6585 }6686 67-// TODO(m4-harden, issue #10): `Origin` check + `SameSite=Lax` live at the68-// HTTP layer (`serve.rs`); we set no cookies and no CORS today, so there69-// is nothing to check yet. #10 adds the check without changing this file.87+/// Count stored files (any regular file counts toward the quota).88+#[must_use]89+pub fn count_inbox_files(inbox_dir: &Path) -> usize {90+ match std::fs::read_dir(inbox_dir) {91+ Ok(rd) => rd.flatten().filter(|e| e.path().is_file()).count(),92+ Err(_) => 0,93+ }94+}95+96+/// `true` = room left (files < quota); `false` = full (`503` until a97+/// human drains the dir by hand).98+#[must_use]99+pub fn check_quota_with(inbox_dir: &Path, quota: usize) -> bool {100+ count_inbox_files(inbox_dir) < quota101+}102+103+// `Origin`/`Referer` check + `SameSite=Lax` live at the HTTP layer104+// (`serve.rs`): we set no cookies and send no CORS headers, so `SameSite`105+// is vacuously satisfied (no `Set-Cookie` ever); the Origin gate fails106+// closed on mismatched browser Origins while curl (no Origin) passes.70107 71108 /// License grant — EXACT final text from `DRAFT.md` (receiver-friendly v1).72109 /// 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 {213250 s.chars().count() <= max214251 }215252 216-/// Validate parsed pairs for `kind`: honeypot → [`Validation::Honeypot`];217-/// bad input → `Reject(400)`; oversize whole shape is enforced by the HTTP218-/// layer (`413`) before we are called.253+/// Validate parsed pairs for `kind` with tuned caps: honeypot →254+/// [`Validation::Honeypot`]; bad input → `Reject(400)`; oversize whole255+/// shape is enforced by the HTTP layer (`413`) before we are called.219256 #[must_use]220-pub fn validate_form(kind: InboxType, pairs: &[(String, String)]) -> Validation {257+pub fn validate_form_with_limits(258+ kind: InboxType,259+ pairs: &[(String, String)],260+ max_title: usize,261+ max_body: usize,262+) -> Validation {221263 if honeypot_filled(pairs) {222264 return Validation::Honeypot;223265 }@@ -250,8 +292,8 @@ pub fn validate_form(kind: InboxType, pairs: &[(String, String)]) -> Validation250292 if title.trim().is_empty() || body.trim().is_empty() {251293 return Validation::Reject(400);252294 }253- if !char_len_ok(&title, MAX_TITLE_CHARS)254- || !char_len_ok(&body, MAX_BODY_CHARS)295+ if !char_len_ok(&title, max_title)296+ || !char_len_ok(&body, max_body)255297 || !char_len_ok(&handle, MAX_HANDLE_CHARS)256298 || !char_len_ok(&contact, MAX_CONTACT_CHARS)257299 {@@ -269,6 +311,15 @@ pub fn validate_form(kind: InboxType, pairs: &[(String, String)]) -> Validation269311 })270312 }271313 314+/// Validate parsed pairs for `kind` with default caps (wrapper over315+/// [`validate_form_with_limits`]). Used by unit tests + external316+/// callers; the POST path threads tuned caps via `with_limits`.317+#[allow(dead_code)]318+#[must_use]319+pub fn validate_form(kind: InboxType, pairs: &[(String, String)]) -> Validation {320+ validate_form_with_limits(kind, pairs, MAX_TITLE_CHARS, MAX_BODY_CHARS)321+}322+272323 /// `received` page: one-time ack uuid, ZERO reflection of input. The same273324 /// page answers real stores and honeypot drops (no oracle).274325 #[must_use]@@ -898,6 +949,61 @@ mod tests {898949 assert!(!html.contains("<script"), "zero JS, zero reflection");899950 }900951 952+ #[test]953+ fn rate_limiter_two_per_hour_sliding_window() {954+ let lim = RateLimiter::new(2);955+ // Same ip+salt+date: first two pass, third blocked.956+ assert!(lim.check_at("1.2.3.4", "2026-09-10", "s", 1_000));957+ assert!(lim.check_at("1.2.3.4", "2026-09-10", "s", 1_001));958+ assert!(!lim.check_at("1.2.3.4", "2026-09-10", "s", 1_002));959+ // Other IP unaffected; window slides after an hour.960+ assert!(lim.check_at("5.6.7.8", "2026-09-10", "s", 1_003));961+ assert!(lim.check_at("1.2.3.4", "2026-09-10", "s", 1_000 + 3601));962+ // Daily rotation + restart salt both re-key (no cross-link).963+ assert!(lim.check_at("1.2.3.4", "2026-09-11", "s", 1_004));964+ assert!(lim.check_at("1.2.3.4", "2026-09-10", "other-salt", 1_005));965+ }966+967+ #[test]968+ fn quota_counts_files_until_drained() {969+ let dir = std::env::temp_dir().join(format!(970+ "hu13-quota-{}-{}",971+ std::process::id(),972+ std::time::SystemTime::now()973+ .duration_since(std::time::UNIX_EPOCH)974+ .map(|d| d.as_nanos())975+ .unwrap_or(0)976+ ));977+ std::fs::create_dir_all(&dir).unwrap();978+ assert!(check_quota_with(&dir, 2));979+ std::fs::write(dir.join("a.json"), "{}").unwrap();980+ assert!(check_quota_with(&dir, 2));981+ std::fs::write(dir.join("b.json"), "{}").unwrap();982+ assert!(!check_quota_with(&dir, 2), "full at quota");983+ std::fs::remove_file(dir.join("a.json")).unwrap();984+ assert!(check_quota_with(&dir, 2), "drained by hand → room again");985+ std::fs::remove_dir_all(&dir).ok();986+ }987+988+ #[test]989+ fn tuned_caps_enforced() {990+ let mk = || {991+ vec![992+ ("title".to_string(), "123456".to_string()),993+ ("body".to_string(), "b".to_string()),994+ ("license".to_string(), "agree".to_string()),995+ ]996+ };997+ assert!(matches!(998+ validate_form_with_limits(InboxType::Issue, &mk(), 5, MAX_BODY_CHARS),999+ Validation::Reject(400)1000+ ));1001+ assert!(matches!(1002+ validate_form_with_limits(InboxType::Issue, &mk(), 6, MAX_BODY_CHARS),1003+ Validation::Accept(_)1004+ ));1005+ }1006+9011007 #[test]9021008 fn forms_have_honeypot_license_action_no_js() {9031009 let dir = std::env::temp_dir().join(format!(
src/main.rs+121 −28--- a/src/main.rs+++ b/src/main.rs@@ -65,13 +65,20 @@ USAGE:6565 hu13 serve [OPTIONS]6666 6767 OPTIONS:68- --www <DIR> Built site directory to serve69- --inbox-dir <DIR> Inbox directory for POST storage (must sit OUTSIDE --www)70- --listen <ADDR> Listen address [default: 0.0.0.0:39313]71- -h, --help Print this help68+ --www <DIR> Built site directory to serve69+ --inbox-dir <DIR> Inbox directory for POST storage (must sit OUTSIDE --www)70+ --listen <ADDR> Listen address [default: 0.0.0.0:39313]71+ --max-title <N> Max title chars [default: 300]72+ --max-body <N> Max body/prompt chars [default: 20000]73+ --max-bytes <N> Max whole POST bytes [default: 32768]74+ --rate-per-hour <N> Per-IP POSTs per hour [default: 2]75+ --quota <N> Global quota in files [default: 500]76+ -h, --help Print this help7277 73-ENV: HU13_WWW, HU13_INBOX_DIR, HU13_LISTEN (flags win).74-POSTS: /inbox/issue + /inbox/pr-prompt only (urlencoded, 32KB cap, ack id).78+ENV: HU13_WWW, HU13_INBOX_DIR, HU13_LISTEN, HU13_MAX_TITLE, HU13_MAX_BODY,79+ HU13_MAX_BYTES, HU13_RATE_PER_HOUR, HU13_QUOTA (flags win).80+ No config file, ever.81+POSTS: /inbox/issue + /inbox/pr-prompt only (urlencoded, ack id, no echo).7582 "7683 );7784 }@@ -213,36 +220,70 @@ fn run_serve(args: &[String]) -> i32 {213220 let mut www: Option<String> = None;214221 let mut inbox_dir: Option<String> = None;215222 let mut listen: Option<String> = None;223+ let mut max_title: Option<String> = None;224+ let mut max_body: Option<String> = None;225+ let mut max_bytes: Option<String> = None;226+ let mut rate_per_hour: Option<String> = None;227+ let mut quota: Option<String> = None;228+ let need = |args: &[String], i: &mut usize, flag: &str| -> Option<String> {229+ *i += 1;230+ if *i >= args.len() {231+ eprintln!("hu13 serve: {flag} needs a value");232+ return None;233+ }234+ Some(args[*i].clone())235+ };216236 let mut i = 0;217237 while i < args.len() {218238 match args[i].as_str() {219239 "--www" => {220- i += 1;221- if i >= args.len() {222- eprintln!("hu13 serve: --www needs a value");240+ let Some(v) = need(args, &mut i, "--www") else {223241 return 2;224- }225- www = Some(args[i].clone());242+ };243+ www = Some(v);226244 }227245 "--inbox-dir" => {228- i += 1;229- if i >= args.len() {230- eprintln!("hu13 serve: --inbox-dir needs a value");246+ let Some(v) = need(args, &mut i, "--inbox-dir") else {231247 return 2;232- }233- inbox_dir = Some(args[i].clone());248+ };249+ inbox_dir = Some(v);234250 }235251 "--listen" => {236- i += 1;237- if i >= args.len() {238- eprintln!("hu13 serve: --listen needs a value");252+ let Some(v) = need(args, &mut i, "--listen") else {239253 return 2;240- }241- listen = Some(args[i].clone());254+ };255+ listen = Some(v);256+ }257+ "--max-title" => {258+ let Some(v) = need(args, &mut i, "--max-title") else {259+ return 2;260+ };261+ max_title = Some(v);262+ }263+ "--max-body" => {264+ let Some(v) = need(args, &mut i, "--max-body") else {265+ return 2;266+ };267+ max_body = Some(v);268+ }269+ "--max-bytes" => {270+ let Some(v) = need(args, &mut i, "--max-bytes") else {271+ return 2;272+ };273+ max_bytes = Some(v);274+ }275+ "--rate-per-hour" => {276+ let Some(v) = need(args, &mut i, "--rate-per-hour") else {277+ return 2;278+ };279+ rate_per_hour = Some(v);280+ }281+ "--quota" => {282+ let Some(v) = need(args, &mut i, "--quota") else {283+ return 2;284+ };285+ quota = Some(v);242286 }243- // TODO(m4-harden, issue #10): cap tunables land here:244- // --max-title/--max-body/--max-bytes/--rate-per-hour/--quota245- // (+ HU13_* env). No config file, ever.246287 s => {247288 eprintln!("hu13 serve: unknown option '{s}'");248289 return 2;@@ -250,6 +291,17 @@ fn run_serve(args: &[String]) -> i32 {250291 }251292 i += 1;252293 }294+ fn parse_num(raw: Option<String>, env: &str, what: &str, dflt: usize) -> Option<usize> {295+ let s = raw.or_else(|| std::env::var(env).ok().filter(|v| !v.trim().is_empty()));296+ let Some(s) = s else { return Some(dflt) };297+ match s.trim().parse::<usize>() {298+ Ok(n) if n > 0 => Some(n),299+ _ => {300+ eprintln!("hu13 serve: {what} (else {env}) must be a positive number, got '{s}'");301+ None302+ }303+ }304+ }253305 let www = env_or(www, "HU13_WWW");254306 let inbox_dir = env_or(inbox_dir, "HU13_INBOX_DIR");255307 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 {257309 eprintln!("hu13 serve: usage: hu13 serve --www <DIR> --inbox-dir <DIR> [--listen <ADDR>]");258310 return 2;259311 };260- serve::run(&serve::ServeOptions {261- www: std::path::PathBuf::from(www),262- inbox_dir: std::path::PathBuf::from(inbox_dir),312+ let Some(max_title) = parse_num(313+ max_title,314+ "HU13_MAX_TITLE",315+ "--max-title",316+ crate::inbox::MAX_TITLE_CHARS,317+ ) else {318+ return 2;319+ };320+ let Some(max_body) = parse_num(321+ max_body,322+ "HU13_MAX_BODY",323+ "--max-body",324+ crate::inbox::MAX_BODY_CHARS,325+ ) else {326+ return 2;327+ };328+ let Some(max_bytes) = parse_num(329+ max_bytes,330+ "HU13_MAX_BYTES",331+ "--max-bytes",332+ crate::inbox::MAX_REQUEST_BYTES,333+ ) else {334+ return 2;335+ };336+ let Some(rate_per_hour) = parse_num(337+ rate_per_hour,338+ "HU13_RATE_PER_HOUR",339+ "--rate-per-hour",340+ crate::inbox::RATE_PER_HOUR as usize,341+ ) else {342+ return 2;343+ };344+ let Some(quota) = parse_num(quota, "HU13_QUOTA", "--quota", crate::inbox::QUOTA_FILES) else {345+ return 2;346+ };347+ let mut opts = serve::ServeOptions::with_defaults(348+ std::path::PathBuf::from(www),349+ std::path::PathBuf::from(inbox_dir),263350 listen,264- })351+ );352+ opts.max_title = max_title;353+ opts.max_body = max_body;354+ opts.max_bytes = max_bytes;355+ opts.rate_per_hour = rate_per_hour as u32;356+ opts.quota = quota;357+ serve::run(&opts)265358 }266359 267360 fn run_inbox(args: &[String]) -> i32 {
src/serve.rs+197 −30--- a/src/serve.rs+++ b/src/serve.rs@@ -9,15 +9,21 @@99 //! (only real files serve; anything else is a generic `404` that echoes1010 //! nothing); `..` / NUL / bad escapes never escape www.1111 //! - POSTs: urlencoded-only (`multipart/*` → `400`), `Content-Length`12-//! required (absent/unparsable → drop the connection), whole body capped13-//! at [`crate::inbox::MAX_REQUEST_BYTES`] (`413`), 5s read timeout14-//! (stall → drop), strict UTF-8 (`400`), NUL → `400`, controls stripped15-//! except `\n\t`, caps validated, license required (`400`), honeypot →16-//! fake `received` + zero files, fixed ~500ms delay before every17-//! inbox-route response, `received` page shows the ack uuid once with18-//! zero reflection. Wrong method on the two routes → `405`.19-//! - TODO(m4-harden, issue #10): rate bucket (`429`), quota (`503`),20-//! `Origin` check hook at the top of [`handle_inbox`].12+//! required (absent/unparsable → drop the connection), whole body13+//! capped (`413`, default 32KB, tunable), 5s read timeout (stall →14+//! drop), strict UTF-8 (`400`), NUL → `400`, controls stripped except15+//! newline/tab, caps validated, license required (`400`), honeypot →16+//! fake `received` + zero files, per-IP token bucket (`429`), global17+//! quota (`503` until drained), `Origin` gate (`403`, curl with no18+//! Origin passes), fixed ~500ms delay before every inbox-route19+//! response (success and failure alike — no timing oracle), `received`20+//! page shows the ack uuid once with zero reflection. Wrong method →21+//! `405`.22+//! - Every inbox-route response carries `nosniff` + `no-store`; we set23+//! no cookies and send no CORS headers, so `SameSite=Lax` is24+//! vacuously satisfied (no `Set-Cookie` ever). Full-disk/IO failure →25+//! `503` (lose one POST, never the repo). Caps tunable via flags/env,26+//! no config.2127 2228 use std::io::{Read, Write};2329 use std::net::{TcpListener, TcpStream};@@ -35,6 +41,45 @@ pub struct ServeOptions {3541 pub inbox_dir: PathBuf,3642 /// Listen address, e.g. `0.0.0.0:39313` (`127.0.0.1:0` in tests).3743 pub listen: String,44+ /// Max chars for `title` (`--max-title` / `HU13_MAX_TITLE`).45+ pub max_title: usize,46+ /// Max chars for `body`/`prompt` (`--max-body` / `HU13_MAX_BODY`).47+ pub max_body: usize,48+ /// Max whole POST body bytes (`--max-bytes` / `HU13_MAX_BYTES`).49+ pub max_bytes: usize,50+ /// Per-IP POSTs per hour (`--rate-per-hour` / `HU13_RATE_PER_HOUR`).51+ pub rate_per_hour: u32,52+ /// Global quota in files (`--quota` / `HU13_QUOTA`).53+ pub quota: usize,54+}55+56+impl ServeOptions {57+ /// Defaults matching [`crate::inbox`] constants (m4 core caps).58+ #[must_use]59+ pub fn with_defaults(www: PathBuf, inbox_dir: PathBuf, listen: String) -> Self {60+ Self {61+ www,62+ inbox_dir,63+ listen,64+ max_title: inbox::MAX_TITLE_CHARS,65+ max_body: inbox::MAX_BODY_CHARS,66+ max_bytes: inbox::MAX_REQUEST_BYTES,67+ rate_per_hour: inbox::RATE_PER_HOUR,68+ quota: inbox::QUOTA_FILES,69+ }70+ }71+}72+73+/// Per-connection shared state (tuned caps + rate buckets + daily salt).74+struct Shared {75+ www: PathBuf,76+ inbox_dir: PathBuf,77+ salt: String,78+ limiter: inbox::RateLimiter,79+ max_title: usize,80+ max_body: usize,81+ max_bytes: usize,82+ quota: usize,3883 }3984 4085 /// Lexically absolutize + normalize `p` (`.`/`..` resolved, no fs touch so@@ -126,14 +171,21 @@ pub fn run(opts: &ServeOptions) -> i32 {126171 println!("hu13: listening on {bound}");127172 use std::io::Write as _;128173 let _ = std::io::stdout().flush();129- let server_salt = inbox::new_server_salt();174+ let shared = std::sync::Arc::new(Shared {175+ www: opts.www.clone(),176+ inbox_dir: opts.inbox_dir.clone(),177+ salt: inbox::new_server_salt(),178+ limiter: inbox::RateLimiter::new(opts.rate_per_hour),179+ max_title: opts.max_title,180+ max_body: opts.max_body,181+ max_bytes: opts.max_bytes,182+ quota: opts.quota,183+ });130184 for conn in listener.incoming() {131185 match conn {132186 Ok(stream) => {133- let www = opts.www.clone();134- let inbox_dir = opts.inbox_dir.clone();135- let salt = server_salt.clone();136- std::thread::spawn(move || handle_conn(stream, &www, &inbox_dir, &salt));187+ let shared = std::sync::Arc::clone(&shared);188+ std::thread::spawn(move || handle_conn(stream, &shared));137189 }138190 Err(e) => {139191 eprintln!("hu13: accept: {e}");@@ -215,6 +267,7 @@ fn reason(code: u16) -> &'static str {215267 match code {216268 200 => "OK",217269 400 => "Bad Request",270+ 403 => "Forbidden",218271 404 => "Not Found",219272 405 => "Method Not Allowed",220273 413 => "Content Too Large",@@ -318,7 +371,72 @@ fn decode_path(s: &str) -> Option<String> {318371 String::from_utf8(bytes).ok()319372 }320373 321-fn handle_conn(mut stream: TcpStream, www: &Path, inbox_dir: &Path, server_salt: &str) {374+/// Hostname of an `Origin`/`Referer` value (`scheme://host[:port][/...]`)375+/// or of a `Host` header (`host[:port]`), lowercased, brackets stripped.376+/// `None` = unparsable (fail closed when it came from the browser).377+fn host_of_url(value: &str) -> Option<String> {378+ let v = value.trim();379+ if (v.is_empty()) || v.eq_ignore_ascii_case("null") {380+ return None;381+ }382+ let after_scheme = match v.split_once("://") {383+ Some((_, rest)) => rest,384+ None => v,385+ };386+ let hostport = after_scheme.split('/').next().unwrap_or("");387+ let hostport = hostport.split('@').next_back().unwrap_or("");388+ if hostport.is_empty() {389+ return None;390+ }391+ // Split port (last `:` with only digits after, or `[v6]:port`).392+ let host = if let Some(stripped) = hostport.strip_prefix('[') {393+ match stripped.split_once(']') {394+ Some((h, _)) => h,395+ None => return None,396+ }397+ } else if hostport.matches(':').count() == 1 {398+ match hostport.split_once(':') {399+ Some((h, port)) if !port.is_empty() && port.bytes().all(|b| b.is_ascii_digit()) => h,400+ Some(_) => return None,401+ None => hostport,402+ }403+ } else {404+ hostport405+ };406+ let host = host.trim().trim_matches('.').to_ascii_lowercase();407+ if host.is_empty() {408+ return None;409+ }410+ Some(host)411+}412+413+/// Same-origin gate for the two POST routes: no `Origin` and no414+/// `Referer` (curl/agents) → allow; browser-supplied `Origin` (else415+/// `Referer`) must match the `Host` hostname → else `403`. Never echoes.416+#[must_use]417+fn origin_allowed(head: &Head) -> bool {418+ let origin = header(head, "origin");419+ let referer = header(head, "referer");420+ let claimed = match origin {421+ Some(o) if !o.trim().is_empty() => Some(o),422+ _ => match referer {423+ Some(r) if !r.trim().is_empty() => Some(r),424+ _ => None,425+ },426+ };427+ let Some(claimed) = claimed else {428+ return true; // curl sends none429+ };430+ let (Some(want), Some(got)) = (431+ header(head, "host").and_then(host_of_url),432+ host_of_url(claimed),433+ ) else {434+ return false; // unparsable Host or Origin → fail closed435+ };436+ want == got437+}438+439+fn handle_conn(mut stream: TcpStream, shared: &Shared) {322440 let _ = stream.set_read_timeout(Some(Duration::from_secs(inbox::POST_READ_TIMEOUT_SECS)));323441 let _ = stream.set_write_timeout(Some(Duration::from_secs(inbox::POST_READ_TIMEOUT_SECS)));324442 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:339457 } else {340458 inbox::InboxType::PrPrompt341459 };342- handle_inbox(&mut stream, &head, rest, kind, inbox_dir, server_salt);460+ handle_inbox(&mut stream, &head, rest, kind, shared);343461 return;344462 }345463 // 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:360478 } else {361479 rel.to_string()362480 };363- let dest = absolutize(&www.join(&file_rel));364- if dest != absolutize(www) && !dest.starts_with(absolutize(www)) {481+ let dest = absolutize(&shared.www.join(&file_rel));482+ if dest != absolutize(&shared.www) && !dest.starts_with(absolutize(&shared.www)) {365483 static_error(&mut stream, 404);366484 return;367485 }@@ -412,8 +530,7 @@ fn handle_inbox(412530 head: &Head,413531 rest: Vec<u8>,414532 kind: inbox::InboxType,415- inbox_dir: &Path,416- server_salt: &str,533+ shared: &Shared,417534 ) {418535 // Inbox-route responses always carry `no-store` and always wait the419536 // fixed ack delay (success and failure alike — no timing oracle).@@ -439,8 +556,14 @@ fn handle_inbox(439556 answer(stream, 405, &body, &[("Allow", "POST")]);440557 return;441558 }442- // TODO(m4-harden, issue #10): `Origin` check goes here (fail closed443- // when a browser sends a mismatched Origin; curl sends none).559+ // Same-origin gate: browsers send Origin/Referer — mismatch fails560+ // closed; curl/agents send neither and pass. Generic page, no echo.561+ if !origin_allowed(head) {562+ settle(stream, rest.len(), content_length(head));563+ let body = inbox::inbox_error_page("forbidden", "forbidden", "Bad origin.");564+ answer(stream, 403, &body, &[]);565+ return;566+ }444567 let ctype = header(head, "content-type")445568 .unwrap_or("")446569 .to_ascii_lowercase();@@ -462,10 +585,9 @@ fn handle_inbox(462585 },463586 None => return,464587 };465- if len > inbox::MAX_REQUEST_BYTES {588+ if len > shared.max_bytes {466589 settle(stream, rest.len(), Some(len));467- let body =468- inbox::inbox_error_page("too large", "too large", "Submissions are capped at 32KB.");590+ let body = inbox::inbox_error_page("too large", "too large", "Submission too large.");469591 answer(stream, 413, &body, &[]);470592 return;471593 }@@ -510,7 +632,7 @@ fn handle_inbox(510632 return;511633 }512634 };513- match inbox::validate_form(kind, &pairs) {635+ match inbox::validate_form_with_limits(kind, &pairs, shared.max_title, shared.max_body) {514636 inbox::Validation::Honeypot => {515637 // Silent `received` with a fresh (unstored) uuid — no oracle.516638 let page = inbox::received_page(&inbox::new_uuid4());@@ -521,24 +643,23 @@ fn handle_inbox(521643 answer(stream, code, &body, &[]);522644 }523645 inbox::Validation::Accept(v) => {524- // TODO(m4-harden, issue #10): stubs wired in — #10 fills them.525646 let ip = stream526647 .peer_addr()527648 .map(|a| a.ip().to_string())528649 .unwrap_or_else(|_| "unknown".to_string());529- if !inbox::check_rate_limit(&ip) {650+ if !shared.limiter.check(&ip, &shared.salt) {530651 let body =531652 inbox::inbox_error_page("too many requests", "too many requests", "Slow down.");532653 answer(stream, 429, &body, &[]);533654 return;534655 }535- if !inbox::check_quota(inbox_dir) {656+ if !inbox::check_quota_with(&shared.inbox_dir, shared.quota) {536657 let body =537658 inbox::inbox_error_page("unavailable", "unavailable", "Inbox full. Try later.");538659 answer(stream, 503, &body, &[]);539660 return;540661 }541- match inbox::store_submission(inbox_dir, &v, &ip, server_salt) {662+ match inbox::store_submission(&shared.inbox_dir, &v, &ip, &shared.salt) {542663 Ok(uuid) => {543664 let page = inbox::received_page(&uuid);544665 answer(stream, 200, &page, &[]);@@ -588,4 +709,50 @@ mod tests {588709 assert!(decode_path("/a%ZZ").is_none(), "bad escape rejected");589710 assert!(decode_path("/plain/path-1_~x").is_some());590711 }712+713+ fn head_with(host: &str, origin: Option<&str>, referer: Option<&str>) -> Head {714+ let mut headers = vec![("host".to_string(), host.to_string())];715+ if let Some(o) = origin {716+ headers.push(("origin".to_string(), o.to_string()));717+ }718+ if let Some(r) = referer {719+ headers.push(("referer".to_string(), r.to_string()));720+ }721+ Head {722+ method: "POST".to_string(),723+ target: "/inbox/issue".to_string(),724+ headers,725+ }726+ }727+728+ #[test]729+ fn origin_gate_allows_curl_blocks_forgery() {730+ assert!(origin_allowed(&head_with("127.0.0.1:1", None, None)));731+ assert!(origin_allowed(&head_with(732+ "127.0.0.1:39313",733+ Some("http://127.0.0.1:39313"),734+ None735+ )));736+ assert!(!origin_allowed(&head_with(737+ "127.0.0.1:39313",738+ Some("https://evil.example"),739+ None740+ )));741+ assert!(!origin_allowed(&head_with(742+ "127.0.0.1:1",743+ Some("null"),744+ None745+ )));746+ // Referer fallback when Origin absent.747+ assert!(origin_allowed(&head_with(748+ "a.example",749+ None,750+ Some("http://a.example/page")751+ )));752+ assert!(!origin_allowed(&head_with(753+ "a.example",754+ None,755+ Some("http://b.example/page")756+ )));757+ }591758 }
tests/abuse.rs+507 −1--- /dev/null+++ b/tests/abuse.rs@@ -0,0 +1,507 @@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+}0--
Comments (0)
No comments yet.
Reviews (0)
No reviews yet.