#10 m4/inbox-harden

mergedpassopened
details
branches
master ← m4/inbox-harden
updated
id
f8e9a060-2a5e-4f48-b96e-e7118380e3cd

Patch

src/inbox.rs+136 −30
diff --git a/src/inbox.rs b/src/inbox.rsindex fc59e73..c6f23ef 100644--- 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
diff --git a/src/main.rs b/src/main.rsindex 20feee5..ee41cba 100644--- 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
diff --git a/src/serve.rs b/src/serve.rsindex 53c61b9..bd878f0 100644--- 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
diff --git a/tests/abuse.rs b/tests/abuse.rsnew file mode 100644index 0000000..9cca7d0--- /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-- 2.55.0

Comments (0)

No comments yet.

Reviews (0)

No reviews yet.

all pulls