.forge/prs/f8e9a060-2a5e-4f48-b96e-e7118380e3cd/patch.diff 45.3 KiB 1276 lines Raw
1 From fc3a6f30ffebcea49c60d0bfbd20e18eb02ffe05 Mon Sep 17 00:00:00 20012 From: gl0w <gl0w@world>3 Date: Thu, 10 Sep 2026 05:56:43 +01004 Subject: [PATCH] m4/inbox-harden: rate/bucket/quota/env tuning + abuse suite5  (101 tests)6 7 ---8  src/inbox.rs   | 166 +++++++++++++---9  src/main.rs    | 149 ++++++++++++---10  src/serve.rs   | 227 +++++++++++++++++++---11  tests/abuse.rs | 507 +++++++++++++++++++++++++++++++++++++++++++++++++12  4 files changed, 961 insertions(+), 88 deletions(-)13  create mode 100644 tests/abuse.rs14 15 diff --git a/src/inbox.rs b/src/inbox.rs16 index fc59e73..c6f23ef 10064417 --- a/src/inbox.rs18 +++ b/src/inbox.rs19 @@ -36,37 +36,74 @@ pub const ACK_DELAY_MS: u64 = 500;20  /// Stored `license_version` for every accepted submission.21  pub const LICENSE_VERSION: &str = "v1";22  23 -// TODO(m4-harden, issue #10): abuse controls live here. Stubs below are24 -// wired into the POST path (they currently allow everything) so #10 only25 -// has to fill in bodies + flags/env tunables, no call-site surgery.26 -27 -/// TODO(m4-harden, issue #10): per-IP bucket (2/hr) — wire flags28 -/// `--rate-per-hour` / `HU13_RATE_PER_HOUR` here.29 +/// Abuse controls (m4-harden, issue #10): per-IP token bucket + global30 +/// quota + tunables. Flags/env only, no config file (see `hu13 serve31 +/// --help`).32 +/// Per-IP bucket capacity per hour (default; `--rate-per-hour` /33 +/// `HU13_RATE_PER_HOUR` overrides per serve run).34  pub const RATE_PER_HOUR: u32 = 2;35 -/// TODO(m4-harden, issue #10): global quota (files) — wire flags36 -/// `--quota` / `HU13_QUOTA` here.37 +/// Global quota default (files; `--quota` / `HU13_QUOTA` overrides).38  pub const QUOTA_FILES: usize = 500;39  40 -/// TODO(m4-harden, issue #10): per-IP token bucket. Currently always41 -/// allows; #10 implements the bucket + `429` path (already wired).42 -#[must_use]43 -pub fn check_rate_limit(_ip: &str) -> bool {44 -    let _ = RATE_PER_HOUR;45 -    true46 +/// Per-IP token bucket (sliding 1h window, capacity = `limit_per_hour`).47 +/// Keys are daily-salted hashes only (`salted_ip_hash(ip, date, salt)`)48 +/// — plaintext IPs never touch disk or logs; memory holds hashes +49 +/// timestamps. A per-startup random `server_salt` keeps restarts50 +/// unlinkable; the `yyyy-mm-dd` date rotates the key daily.51 +/// `429` when the bucket is empty.52 +pub struct RateLimiter {53 +    limit_per_hour: u32,54 +    buckets: std::sync::Mutex<std::collections::HashMap<String, Vec<u64>>>,55  }56  57 -/// TODO(m4-harden, issue #10): global quota (`503` when full until drained58 -/// by hand). Currently always allows; #10 implements the count + `503`59 -/// path (already wired).60 -#[must_use]61 -pub fn check_quota(_inbox_dir: &Path) -> bool {62 -    let _ = QUOTA_FILES;63 -    true64 +impl RateLimiter {65 +    #[must_use]66 +    pub fn new(limit_per_hour: u32) -> Self {67 +        Self {68 +            limit_per_hour,69 +            buckets: std::sync::Mutex::new(std::collections::HashMap::new()),70 +        }71 +    }72 +73 +    fn check_at(&self, ip: &str, date: &str, server_salt: &str, now: u64) -> bool {74 +        let key = salted_ip_hash(ip, date, server_salt);75 +        let mut map = self.buckets.lock().unwrap_or_else(|e| e.into_inner());76 +        let entry = map.entry(key).or_default();77 +        entry.retain(|&t| now.wrapping_sub(t) < 3600);78 +        if (entry.len() as u32) >= self.limit_per_hour {79 +            return false;80 +        }81 +        entry.push(now);82 +        true83 +    }84 +85 +    /// Record one accepted POST from `ip`; `false` = over budget (`429`).86 +    #[must_use]87 +    pub fn check(&self, ip: &str, server_salt: &str) -> bool {88 +        self.check_at(ip, &utc_today(), server_salt, unix_secs())89 +    }90  }91  92 -// TODO(m4-harden, issue #10): `Origin` check + `SameSite=Lax` live at the93 -// HTTP layer (`serve.rs`); we set no cookies and no CORS today, so there94 -// is nothing to check yet. #10 adds the check without changing this file.95 +/// Count stored files (any regular file counts toward the quota).96 +#[must_use]97 +pub fn count_inbox_files(inbox_dir: &Path) -> usize {98 +    match std::fs::read_dir(inbox_dir) {99 +        Ok(rd) => rd.flatten().filter(|e| e.path().is_file()).count(),100 +        Err(_) => 0,101 +    }102 +}103 +104 +/// `true` = room left (files < quota); `false` = full (`503` until a105 +/// human drains the dir by hand).106 +#[must_use]107 +pub fn check_quota_with(inbox_dir: &Path, quota: usize) -> bool {108 +    count_inbox_files(inbox_dir) < quota109 +}110 +111 +// `Origin`/`Referer` check + `SameSite=Lax` live at the HTTP layer112 +// (`serve.rs`): we set no cookies and send no CORS headers, so `SameSite`113 +// is vacuously satisfied (no `Set-Cookie` ever); the Origin gate fails114 +// closed on mismatched browser Origins while curl (no Origin) passes.115  116  /// License grant — EXACT final text from `DRAFT.md` (receiver-friendly v1).117  /// The forms, the agents page, and storage all derive from this one118 @@ -213,11 +250,16 @@ fn char_len_ok(s: &str, max: usize) -> bool {119      s.chars().count() <= max120  }121  122 -/// Validate parsed pairs for `kind`: honeypot → [`Validation::Honeypot`];123 -/// bad input → `Reject(400)`; oversize whole shape is enforced by the HTTP124 -/// layer (`413`) before we are called.125 +/// Validate parsed pairs for `kind` with tuned caps: honeypot →126 +/// [`Validation::Honeypot`]; bad input → `Reject(400)`; oversize whole127 +/// shape is enforced by the HTTP layer (`413`) before we are called.128  #[must_use]129 -pub fn validate_form(kind: InboxType, pairs: &[(String, String)]) -> Validation {130 +pub fn validate_form_with_limits(131 +    kind: InboxType,132 +    pairs: &[(String, String)],133 +    max_title: usize,134 +    max_body: usize,135 +) -> Validation {136      if honeypot_filled(pairs) {137          return Validation::Honeypot;138      }139 @@ -250,8 +292,8 @@ pub fn validate_form(kind: InboxType, pairs: &[(String, String)]) -> Validation140      if title.trim().is_empty() || body.trim().is_empty() {141          return Validation::Reject(400);142      }143 -    if !char_len_ok(&title, MAX_TITLE_CHARS)144 -        || !char_len_ok(&body, MAX_BODY_CHARS)145 +    if !char_len_ok(&title, max_title)146 +        || !char_len_ok(&body, max_body)147          || !char_len_ok(&handle, MAX_HANDLE_CHARS)148          || !char_len_ok(&contact, MAX_CONTACT_CHARS)149      {150 @@ -269,6 +311,15 @@ pub fn validate_form(kind: InboxType, pairs: &[(String, String)]) -> Validation151      })152  }153  154 +/// Validate parsed pairs for `kind` with default caps (wrapper over155 +/// [`validate_form_with_limits`]). Used by unit tests + external156 +/// callers; the POST path threads tuned caps via `with_limits`.157 +#[allow(dead_code)]158 +#[must_use]159 +pub fn validate_form(kind: InboxType, pairs: &[(String, String)]) -> Validation {160 +    validate_form_with_limits(kind, pairs, MAX_TITLE_CHARS, MAX_BODY_CHARS)161 +}162 +163  /// `received` page: one-time ack uuid, ZERO reflection of input. The same164  /// page answers real stores and honeypot drops (no oracle).165  #[must_use]166 @@ -898,6 +949,61 @@ mod tests {167          assert!(!html.contains("<script"), "zero JS, zero reflection");168      }169  170 +    #[test]171 +    fn rate_limiter_two_per_hour_sliding_window() {172 +        let lim = RateLimiter::new(2);173 +        // Same ip+salt+date: first two pass, third blocked.174 +        assert!(lim.check_at("1.2.3.4", "2026-09-10", "s", 1_000));175 +        assert!(lim.check_at("1.2.3.4", "2026-09-10", "s", 1_001));176 +        assert!(!lim.check_at("1.2.3.4", "2026-09-10", "s", 1_002));177 +        // Other IP unaffected; window slides after an hour.178 +        assert!(lim.check_at("5.6.7.8", "2026-09-10", "s", 1_003));179 +        assert!(lim.check_at("1.2.3.4", "2026-09-10", "s", 1_000 + 3601));180 +        // Daily rotation + restart salt both re-key (no cross-link).181 +        assert!(lim.check_at("1.2.3.4", "2026-09-11", "s", 1_004));182 +        assert!(lim.check_at("1.2.3.4", "2026-09-10", "other-salt", 1_005));183 +    }184 +185 +    #[test]186 +    fn quota_counts_files_until_drained() {187 +        let dir = std::env::temp_dir().join(format!(188 +            "hu13-quota-{}-{}",189 +            std::process::id(),190 +            std::time::SystemTime::now()191 +                .duration_since(std::time::UNIX_EPOCH)192 +                .map(|d| d.as_nanos())193 +                .unwrap_or(0)194 +        ));195 +        std::fs::create_dir_all(&dir).unwrap();196 +        assert!(check_quota_with(&dir, 2));197 +        std::fs::write(dir.join("a.json"), "{}").unwrap();198 +        assert!(check_quota_with(&dir, 2));199 +        std::fs::write(dir.join("b.json"), "{}").unwrap();200 +        assert!(!check_quota_with(&dir, 2), "full at quota");201 +        std::fs::remove_file(dir.join("a.json")).unwrap();202 +        assert!(check_quota_with(&dir, 2), "drained by hand → room again");203 +        std::fs::remove_dir_all(&dir).ok();204 +    }205 +206 +    #[test]207 +    fn tuned_caps_enforced() {208 +        let mk = || {209 +            vec![210 +                ("title".to_string(), "123456".to_string()),211 +                ("body".to_string(), "b".to_string()),212 +                ("license".to_string(), "agree".to_string()),213 +            ]214 +        };215 +        assert!(matches!(216 +            validate_form_with_limits(InboxType::Issue, &mk(), 5, MAX_BODY_CHARS),217 +            Validation::Reject(400)218 +        ));219 +        assert!(matches!(220 +            validate_form_with_limits(InboxType::Issue, &mk(), 6, MAX_BODY_CHARS),221 +            Validation::Accept(_)222 +        ));223 +    }224 +225      #[test]226      fn forms_have_honeypot_license_action_no_js() {227          let dir = std::env::temp_dir().join(format!(228 diff --git a/src/main.rs b/src/main.rs229 index 20feee5..ee41cba 100644230 --- a/src/main.rs231 +++ b/src/main.rs232 @@ -65,13 +65,20 @@ USAGE:233      hu13 serve [OPTIONS]234  235  OPTIONS:236 -        --www <DIR>        Built site directory to serve237 -        --inbox-dir <DIR>  Inbox directory for POST storage (must sit OUTSIDE --www)238 -        --listen <ADDR>    Listen address [default: 0.0.0.0:39313]239 -    -h, --help             Print this help240 +        --www <DIR>          Built site directory to serve241 +        --inbox-dir <DIR>    Inbox directory for POST storage (must sit OUTSIDE --www)242 +        --listen <ADDR>      Listen address [default: 0.0.0.0:39313]243 +        --max-title <N>      Max title chars [default: 300]244 +        --max-body <N>       Max body/prompt chars [default: 20000]245 +        --max-bytes <N>      Max whole POST bytes [default: 32768]246 +        --rate-per-hour <N>  Per-IP POSTs per hour [default: 2]247 +        --quota <N>          Global quota in files [default: 500]248 +    -h, --help               Print this help249  250 -ENV: HU13_WWW, HU13_INBOX_DIR, HU13_LISTEN (flags win).251 -POSTS: /inbox/issue + /inbox/pr-prompt only (urlencoded, 32KB cap, ack id).252 +ENV: HU13_WWW, HU13_INBOX_DIR, HU13_LISTEN, HU13_MAX_TITLE, HU13_MAX_BODY,253 +     HU13_MAX_BYTES, HU13_RATE_PER_HOUR, HU13_QUOTA (flags win).254 +     No config file, ever.255 +POSTS: /inbox/issue + /inbox/pr-prompt only (urlencoded, ack id, no echo).256  "257      );258  }259 @@ -213,36 +220,70 @@ fn run_serve(args: &[String]) -> i32 {260      let mut www: Option<String> = None;261      let mut inbox_dir: Option<String> = None;262      let mut listen: Option<String> = None;263 +    let mut max_title: Option<String> = None;264 +    let mut max_body: Option<String> = None;265 +    let mut max_bytes: Option<String> = None;266 +    let mut rate_per_hour: Option<String> = None;267 +    let mut quota: Option<String> = None;268 +    let need = |args: &[String], i: &mut usize, flag: &str| -> Option<String> {269 +        *i += 1;270 +        if *i >= args.len() {271 +            eprintln!("hu13 serve: {flag} needs a value");272 +            return None;273 +        }274 +        Some(args[*i].clone())275 +    };276      let mut i = 0;277      while i < args.len() {278          match args[i].as_str() {279              "--www" => {280 -                i += 1;281 -                if i >= args.len() {282 -                    eprintln!("hu13 serve: --www needs a value");283 +                let Some(v) = need(args, &mut i, "--www") else {284                      return 2;285 -                }286 -                www = Some(args[i].clone());287 +                };288 +                www = Some(v);289              }290              "--inbox-dir" => {291 -                i += 1;292 -                if i >= args.len() {293 -                    eprintln!("hu13 serve: --inbox-dir needs a value");294 +                let Some(v) = need(args, &mut i, "--inbox-dir") else {295                      return 2;296 -                }297 -                inbox_dir = Some(args[i].clone());298 +                };299 +                inbox_dir = Some(v);300              }301              "--listen" => {302 -                i += 1;303 -                if i >= args.len() {304 -                    eprintln!("hu13 serve: --listen needs a value");305 +                let Some(v) = need(args, &mut i, "--listen") else {306                      return 2;307 -                }308 -                listen = Some(args[i].clone());309 +                };310 +                listen = Some(v);311 +            }312 +            "--max-title" => {313 +                let Some(v) = need(args, &mut i, "--max-title") else {314 +                    return 2;315 +                };316 +                max_title = Some(v);317 +            }318 +            "--max-body" => {319 +                let Some(v) = need(args, &mut i, "--max-body") else {320 +                    return 2;321 +                };322 +                max_body = Some(v);323 +            }324 +            "--max-bytes" => {325 +                let Some(v) = need(args, &mut i, "--max-bytes") else {326 +                    return 2;327 +                };328 +                max_bytes = Some(v);329 +            }330 +            "--rate-per-hour" => {331 +                let Some(v) = need(args, &mut i, "--rate-per-hour") else {332 +                    return 2;333 +                };334 +                rate_per_hour = Some(v);335 +            }336 +            "--quota" => {337 +                let Some(v) = need(args, &mut i, "--quota") else {338 +                    return 2;339 +                };340 +                quota = Some(v);341              }342 -            // TODO(m4-harden, issue #10): cap tunables land here:343 -            // --max-title/--max-body/--max-bytes/--rate-per-hour/--quota344 -            // (+ HU13_* env). No config file, ever.345              s => {346                  eprintln!("hu13 serve: unknown option '{s}'");347                  return 2;348 @@ -250,6 +291,17 @@ fn run_serve(args: &[String]) -> i32 {349          }350          i += 1;351      }352 +    fn parse_num(raw: Option<String>, env: &str, what: &str, dflt: usize) -> Option<usize> {353 +        let s = raw.or_else(|| std::env::var(env).ok().filter(|v| !v.trim().is_empty()));354 +        let Some(s) = s else { return Some(dflt) };355 +        match s.trim().parse::<usize>() {356 +            Ok(n) if n > 0 => Some(n),357 +            _ => {358 +                eprintln!("hu13 serve: {what} (else {env}) must be a positive number, got '{s}'");359 +                None360 +            }361 +        }362 +    }363      let www = env_or(www, "HU13_WWW");364      let inbox_dir = env_or(inbox_dir, "HU13_INBOX_DIR");365      let listen = env_or(listen, "HU13_LISTEN").unwrap_or_else(|| "0.0.0.0:39313".to_string());366 @@ -257,11 +309,52 @@ fn run_serve(args: &[String]) -> i32 {367          eprintln!("hu13 serve: usage: hu13 serve --www <DIR> --inbox-dir <DIR> [--listen <ADDR>]");368          return 2;369      };370 -    serve::run(&serve::ServeOptions {371 -        www: std::path::PathBuf::from(www),372 -        inbox_dir: std::path::PathBuf::from(inbox_dir),373 +    let Some(max_title) = parse_num(374 +        max_title,375 +        "HU13_MAX_TITLE",376 +        "--max-title",377 +        crate::inbox::MAX_TITLE_CHARS,378 +    ) else {379 +        return 2;380 +    };381 +    let Some(max_body) = parse_num(382 +        max_body,383 +        "HU13_MAX_BODY",384 +        "--max-body",385 +        crate::inbox::MAX_BODY_CHARS,386 +    ) else {387 +        return 2;388 +    };389 +    let Some(max_bytes) = parse_num(390 +        max_bytes,391 +        "HU13_MAX_BYTES",392 +        "--max-bytes",393 +        crate::inbox::MAX_REQUEST_BYTES,394 +    ) else {395 +        return 2;396 +    };397 +    let Some(rate_per_hour) = parse_num(398 +        rate_per_hour,399 +        "HU13_RATE_PER_HOUR",400 +        "--rate-per-hour",401 +        crate::inbox::RATE_PER_HOUR as usize,402 +    ) else {403 +        return 2;404 +    };405 +    let Some(quota) = parse_num(quota, "HU13_QUOTA", "--quota", crate::inbox::QUOTA_FILES) else {406 +        return 2;407 +    };408 +    let mut opts = serve::ServeOptions::with_defaults(409 +        std::path::PathBuf::from(www),410 +        std::path::PathBuf::from(inbox_dir),411          listen,412 -    })413 +    );414 +    opts.max_title = max_title;415 +    opts.max_body = max_body;416 +    opts.max_bytes = max_bytes;417 +    opts.rate_per_hour = rate_per_hour as u32;418 +    opts.quota = quota;419 +    serve::run(&opts)420  }421  422  fn run_inbox(args: &[String]) -> i32 {423 diff --git a/src/serve.rs b/src/serve.rs424 index 53c61b9..bd878f0 100644425 --- a/src/serve.rs426 +++ b/src/serve.rs427 @@ -9,15 +9,21 @@428  //!   (only real files serve; anything else is a generic `404` that echoes429  //!   nothing); `..` / NUL / bad escapes never escape www.430  //! - POSTs: urlencoded-only (`multipart/*` → `400`), `Content-Length`431 -//!   required (absent/unparsable → drop the connection), whole body capped432 -//!   at [`crate::inbox::MAX_REQUEST_BYTES`] (`413`), 5s read timeout433 -//!   (stall → drop), strict UTF-8 (`400`), NUL → `400`, controls stripped434 -//!   except `\n\t`, caps validated, license required (`400`), honeypot →435 -//!   fake `received` + zero files, fixed ~500ms delay before every436 -//!   inbox-route response, `received` page shows the ack uuid once with437 -//!   zero reflection. Wrong method on the two routes → `405`.438 -//! - TODO(m4-harden, issue #10): rate bucket (`429`), quota (`503`),439 -//!   `Origin` check hook at the top of [`handle_inbox`].440 +//!   required (absent/unparsable → drop the connection), whole body441 +//!   capped (`413`, default 32KB, tunable), 5s read timeout (stall →442 +//!   drop), strict UTF-8 (`400`), NUL → `400`, controls stripped except443 +//!   newline/tab, caps validated, license required (`400`), honeypot →444 +//!   fake `received` + zero files, per-IP token bucket (`429`), global445 +//!   quota (`503` until drained), `Origin` gate (`403`, curl with no446 +//!   Origin passes), fixed ~500ms delay before every inbox-route447 +//!   response (success and failure alike — no timing oracle), `received`448 +//!   page shows the ack uuid once with zero reflection. Wrong method →449 +//!   `405`.450 +//! - Every inbox-route response carries `nosniff` + `no-store`; we set451 +//!   no cookies and send no CORS headers, so `SameSite=Lax` is452 +//!   vacuously satisfied (no `Set-Cookie` ever). Full-disk/IO failure →453 +//!   `503` (lose one POST, never the repo). Caps tunable via flags/env,454 +//!   no config.455  456  use std::io::{Read, Write};457  use std::net::{TcpListener, TcpStream};458 @@ -35,6 +41,45 @@ pub struct ServeOptions {459      pub inbox_dir: PathBuf,460      /// Listen address, e.g. `0.0.0.0:39313` (`127.0.0.1:0` in tests).461      pub listen: String,462 +    /// Max chars for `title` (`--max-title` / `HU13_MAX_TITLE`).463 +    pub max_title: usize,464 +    /// Max chars for `body`/`prompt` (`--max-body` / `HU13_MAX_BODY`).465 +    pub max_body: usize,466 +    /// Max whole POST body bytes (`--max-bytes` / `HU13_MAX_BYTES`).467 +    pub max_bytes: usize,468 +    /// Per-IP POSTs per hour (`--rate-per-hour` / `HU13_RATE_PER_HOUR`).469 +    pub rate_per_hour: u32,470 +    /// Global quota in files (`--quota` / `HU13_QUOTA`).471 +    pub quota: usize,472 +}473 +474 +impl ServeOptions {475 +    /// Defaults matching [`crate::inbox`] constants (m4 core caps).476 +    #[must_use]477 +    pub fn with_defaults(www: PathBuf, inbox_dir: PathBuf, listen: String) -> Self {478 +        Self {479 +            www,480 +            inbox_dir,481 +            listen,482 +            max_title: inbox::MAX_TITLE_CHARS,483 +            max_body: inbox::MAX_BODY_CHARS,484 +            max_bytes: inbox::MAX_REQUEST_BYTES,485 +            rate_per_hour: inbox::RATE_PER_HOUR,486 +            quota: inbox::QUOTA_FILES,487 +        }488 +    }489 +}490 +491 +/// Per-connection shared state (tuned caps + rate buckets + daily salt).492 +struct Shared {493 +    www: PathBuf,494 +    inbox_dir: PathBuf,495 +    salt: String,496 +    limiter: inbox::RateLimiter,497 +    max_title: usize,498 +    max_body: usize,499 +    max_bytes: usize,500 +    quota: usize,501  }502  503  /// Lexically absolutize + normalize `p` (`.`/`..` resolved, no fs touch so504 @@ -126,14 +171,21 @@ pub fn run(opts: &ServeOptions) -> i32 {505      println!("hu13: listening on {bound}");506      use std::io::Write as _;507      let _ = std::io::stdout().flush();508 -    let server_salt = inbox::new_server_salt();509 +    let shared = std::sync::Arc::new(Shared {510 +        www: opts.www.clone(),511 +        inbox_dir: opts.inbox_dir.clone(),512 +        salt: inbox::new_server_salt(),513 +        limiter: inbox::RateLimiter::new(opts.rate_per_hour),514 +        max_title: opts.max_title,515 +        max_body: opts.max_body,516 +        max_bytes: opts.max_bytes,517 +        quota: opts.quota,518 +    });519      for conn in listener.incoming() {520          match conn {521              Ok(stream) => {522 -                let www = opts.www.clone();523 -                let inbox_dir = opts.inbox_dir.clone();524 -                let salt = server_salt.clone();525 -                std::thread::spawn(move || handle_conn(stream, &www, &inbox_dir, &salt));526 +                let shared = std::sync::Arc::clone(&shared);527 +                std::thread::spawn(move || handle_conn(stream, &shared));528              }529              Err(e) => {530                  eprintln!("hu13: accept: {e}");531 @@ -215,6 +267,7 @@ fn reason(code: u16) -> &'static str {532      match code {533          200 => "OK",534          400 => "Bad Request",535 +        403 => "Forbidden",536          404 => "Not Found",537          405 => "Method Not Allowed",538          413 => "Content Too Large",539 @@ -318,7 +371,72 @@ fn decode_path(s: &str) -> Option<String> {540      String::from_utf8(bytes).ok()541  }542  543 -fn handle_conn(mut stream: TcpStream, www: &Path, inbox_dir: &Path, server_salt: &str) {544 +/// Hostname of an `Origin`/`Referer` value (`scheme://host[:port][/...]`)545 +/// or of a `Host` header (`host[:port]`), lowercased, brackets stripped.546 +/// `None` = unparsable (fail closed when it came from the browser).547 +fn host_of_url(value: &str) -> Option<String> {548 +    let v = value.trim();549 +    if (v.is_empty()) || v.eq_ignore_ascii_case("null") {550 +        return None;551 +    }552 +    let after_scheme = match v.split_once("://") {553 +        Some((_, rest)) => rest,554 +        None => v,555 +    };556 +    let hostport = after_scheme.split('/').next().unwrap_or("");557 +    let hostport = hostport.split('@').next_back().unwrap_or("");558 +    if hostport.is_empty() {559 +        return None;560 +    }561 +    // Split port (last `:` with only digits after, or `[v6]:port`).562 +    let host = if let Some(stripped) = hostport.strip_prefix('[') {563 +        match stripped.split_once(']') {564 +            Some((h, _)) => h,565 +            None => return None,566 +        }567 +    } else if hostport.matches(':').count() == 1 {568 +        match hostport.split_once(':') {569 +            Some((h, port)) if !port.is_empty() && port.bytes().all(|b| b.is_ascii_digit()) => h,570 +            Some(_) => return None,571 +            None => hostport,572 +        }573 +    } else {574 +        hostport575 +    };576 +    let host = host.trim().trim_matches('.').to_ascii_lowercase();577 +    if host.is_empty() {578 +        return None;579 +    }580 +    Some(host)581 +}582 +583 +/// Same-origin gate for the two POST routes: no `Origin` and no584 +/// `Referer` (curl/agents) → allow; browser-supplied `Origin` (else585 +/// `Referer`) must match the `Host` hostname → else `403`. Never echoes.586 +#[must_use]587 +fn origin_allowed(head: &Head) -> bool {588 +    let origin = header(head, "origin");589 +    let referer = header(head, "referer");590 +    let claimed = match origin {591 +        Some(o) if !o.trim().is_empty() => Some(o),592 +        _ => match referer {593 +            Some(r) if !r.trim().is_empty() => Some(r),594 +            _ => None,595 +        },596 +    };597 +    let Some(claimed) = claimed else {598 +        return true; // curl sends none599 +    };600 +    let (Some(want), Some(got)) = (601 +        header(head, "host").and_then(host_of_url),602 +        host_of_url(claimed),603 +    ) else {604 +        return false; // unparsable Host or Origin → fail closed605 +    };606 +    want == got607 +}608 +609 +fn handle_conn(mut stream: TcpStream, shared: &Shared) {610      let _ = stream.set_read_timeout(Some(Duration::from_secs(inbox::POST_READ_TIMEOUT_SECS)));611      let _ = stream.set_write_timeout(Some(Duration::from_secs(inbox::POST_READ_TIMEOUT_SECS)));612      let (head, rest) = match read_head(&mut stream) {613 @@ -339,7 +457,7 @@ fn handle_conn(mut stream: TcpStream, www: &Path, inbox_dir: &Path, server_salt:614          } else {615              inbox::InboxType::PrPrompt616          };617 -        handle_inbox(&mut stream, &head, rest, kind, inbox_dir, server_salt);618 +        handle_inbox(&mut stream, &head, rest, kind, shared);619          return;620      }621      // Static: GET/HEAD only; never a listing (dirs → 404, files only).622 @@ -360,8 +478,8 @@ fn handle_conn(mut stream: TcpStream, www: &Path, inbox_dir: &Path, server_salt:623      } else {624          rel.to_string()625      };626 -    let dest = absolutize(&www.join(&file_rel));627 -    if dest != absolutize(www) && !dest.starts_with(absolutize(www)) {628 +    let dest = absolutize(&shared.www.join(&file_rel));629 +    if dest != absolutize(&shared.www) && !dest.starts_with(absolutize(&shared.www)) {630          static_error(&mut stream, 404);631          return;632      }633 @@ -412,8 +530,7 @@ fn handle_inbox(634      head: &Head,635      rest: Vec<u8>,636      kind: inbox::InboxType,637 -    inbox_dir: &Path,638 -    server_salt: &str,639 +    shared: &Shared,640  ) {641      // Inbox-route responses always carry `no-store` and always wait the642      // fixed ack delay (success and failure alike — no timing oracle).643 @@ -439,8 +556,14 @@ fn handle_inbox(644          answer(stream, 405, &body, &[("Allow", "POST")]);645          return;646      }647 -    // TODO(m4-harden, issue #10): `Origin` check goes here (fail closed648 -    // when a browser sends a mismatched Origin; curl sends none).649 +    // Same-origin gate: browsers send Origin/Referer — mismatch fails650 +    // closed; curl/agents send neither and pass. Generic page, no echo.651 +    if !origin_allowed(head) {652 +        settle(stream, rest.len(), content_length(head));653 +        let body = inbox::inbox_error_page("forbidden", "forbidden", "Bad origin.");654 +        answer(stream, 403, &body, &[]);655 +        return;656 +    }657      let ctype = header(head, "content-type")658          .unwrap_or("")659          .to_ascii_lowercase();660 @@ -462,10 +585,9 @@ fn handle_inbox(661          },662          None => return,663      };664 -    if len > inbox::MAX_REQUEST_BYTES {665 +    if len > shared.max_bytes {666          settle(stream, rest.len(), Some(len));667 -        let body =668 -            inbox::inbox_error_page("too large", "too large", "Submissions are capped at 32KB.");669 +        let body = inbox::inbox_error_page("too large", "too large", "Submission too large.");670          answer(stream, 413, &body, &[]);671          return;672      }673 @@ -510,7 +632,7 @@ fn handle_inbox(674              return;675          }676      };677 -    match inbox::validate_form(kind, &pairs) {678 +    match inbox::validate_form_with_limits(kind, &pairs, shared.max_title, shared.max_body) {679          inbox::Validation::Honeypot => {680              // Silent `received` with a fresh (unstored) uuid — no oracle.681              let page = inbox::received_page(&inbox::new_uuid4());682 @@ -521,24 +643,23 @@ fn handle_inbox(683              answer(stream, code, &body, &[]);684          }685          inbox::Validation::Accept(v) => {686 -            // TODO(m4-harden, issue #10): stubs wired in — #10 fills them.687              let ip = stream688                  .peer_addr()689                  .map(|a| a.ip().to_string())690                  .unwrap_or_else(|_| "unknown".to_string());691 -            if !inbox::check_rate_limit(&ip) {692 +            if !shared.limiter.check(&ip, &shared.salt) {693                  let body =694                      inbox::inbox_error_page("too many requests", "too many requests", "Slow down.");695                  answer(stream, 429, &body, &[]);696                  return;697              }698 -            if !inbox::check_quota(inbox_dir) {699 +            if !inbox::check_quota_with(&shared.inbox_dir, shared.quota) {700                  let body =701                      inbox::inbox_error_page("unavailable", "unavailable", "Inbox full. Try later.");702                  answer(stream, 503, &body, &[]);703                  return;704              }705 -            match inbox::store_submission(inbox_dir, &v, &ip, server_salt) {706 +            match inbox::store_submission(&shared.inbox_dir, &v, &ip, &shared.salt) {707                  Ok(uuid) => {708                      let page = inbox::received_page(&uuid);709                      answer(stream, 200, &page, &[]);710 @@ -588,4 +709,50 @@ mod tests {711          assert!(decode_path("/a%ZZ").is_none(), "bad escape rejected");712          assert!(decode_path("/plain/path-1_~x").is_some());713      }714 +715 +    fn head_with(host: &str, origin: Option<&str>, referer: Option<&str>) -> Head {716 +        let mut headers = vec![("host".to_string(), host.to_string())];717 +        if let Some(o) = origin {718 +            headers.push(("origin".to_string(), o.to_string()));719 +        }720 +        if let Some(r) = referer {721 +            headers.push(("referer".to_string(), r.to_string()));722 +        }723 +        Head {724 +            method: "POST".to_string(),725 +            target: "/inbox/issue".to_string(),726 +            headers,727 +        }728 +    }729 +730 +    #[test]731 +    fn origin_gate_allows_curl_blocks_forgery() {732 +        assert!(origin_allowed(&head_with("127.0.0.1:1", None, None)));733 +        assert!(origin_allowed(&head_with(734 +            "127.0.0.1:39313",735 +            Some("http://127.0.0.1:39313"),736 +            None737 +        )));738 +        assert!(!origin_allowed(&head_with(739 +            "127.0.0.1:39313",740 +            Some("https://evil.example"),741 +            None742 +        )));743 +        assert!(!origin_allowed(&head_with(744 +            "127.0.0.1:1",745 +            Some("null"),746 +            None747 +        )));748 +        // Referer fallback when Origin absent.749 +        assert!(origin_allowed(&head_with(750 +            "a.example",751 +            None,752 +            Some("http://a.example/page")753 +        )));754 +        assert!(!origin_allowed(&head_with(755 +            "a.example",756 +            None,757 +            Some("http://b.example/page")758 +        )));759 +    }760  }761 diff --git a/tests/abuse.rs b/tests/abuse.rs762 new file mode 100644763 index 0000000..9cca7d0764 --- /dev/null765 +++ b/tests/abuse.rs766 @@ -0,0 +1,507 @@767 +//! Inbox abuse suite (m4-harden, f0rge issue #10): adversarial checks.768 +//!769 +//! Each test spawns the real binary (`hu13 serve --listen 127.0.0.1:0`)770 +//! and talks raw HTTP over `TcpStream`. Suite must run in <60s: few771 +//! POSTs per test (each costs the fixed ~500ms ack delay by design).772 +773 +use std::collections::HashMap;774 +use std::io::{BufRead, BufReader, Read, Write};775 +use std::net::TcpStream;776 +use std::path::{Path, PathBuf};777 +use std::process::{Child, Command, Stdio};778 +use std::sync::atomic::{AtomicU64, Ordering};779 +use std::time::{Duration, Instant};780 +781 +static COUNTER: AtomicU64 = AtomicU64::new(0);782 +783 +fn unique(prefix: &str) -> PathBuf {784 +    let id = COUNTER.fetch_add(1, Ordering::SeqCst);785 +    std::env::temp_dir().join(format!(786 +        "hu13-abuse-{prefix}-{}-{id}-{}",787 +        std::process::id(),788 +        std::time::SystemTime::now()789 +            .duration_since(std::time::UNIX_EPOCH)790 +            .map(|d| d.as_nanos())791 +            .unwrap_or(0)792 +    ))793 +}794 +795 +struct TmpDir(PathBuf);796 +impl TmpDir {797 +    fn fresh(prefix: &str) -> Self {798 +        let p = unique(prefix);799 +        std::fs::create_dir_all(&p).unwrap();800 +        Self(p)801 +    }802 +    fn path(&self) -> &Path {803 +        &self.0804 +    }805 +}806 +impl Drop for TmpDir {807 +    fn drop(&mut self) {808 +        std::fs::remove_dir_all(&self.0).ok();809 +    }810 +}811 +812 +fn bin() -> &'static str {813 +    env!("CARGO_BIN_EXE_hu13")814 +}815 +816 +struct Server {817 +    child: Child,818 +    addr: String,819 +    _www: Option<TmpDir>,820 +    _inbox_owned: Option<TmpDir>,821 +    inbox_path: PathBuf,822 +}823 +impl Server {824 +    fn inbox_files(&self) -> Vec<PathBuf> {825 +        let mut out: Vec<PathBuf> = std::fs::read_dir(&self.inbox_path)826 +            .map(|rd| {827 +                rd.flatten()828 +                    .map(|e| e.path())829 +                    .filter(|p| p.is_file())830 +                    .collect()831 +            })832 +            .unwrap_or_default();833 +        out.sort();834 +        out835 +    }836 +}837 +impl Drop for Server {838 +    fn drop(&mut self) {839 +        self.child.kill().ok();840 +        self.child.wait().ok();841 +    }842 +}843 +844 +fn start_with(845 +    www: TmpDir,846 +    inbox: Option<TmpDir>,847 +    inbox_path: PathBuf,848 +    extra_args: &[&str],849 +    envs: &HashMap<String, String>,850 +) -> (Server, TmpDir) {851 +    let mut cmd = Command::new(bin());852 +    cmd.arg("serve")853 +        .arg("--www")854 +        .arg(www.path())855 +        .arg("--inbox-dir")856 +        .arg(&inbox_path)857 +        .arg("--listen")858 +        .arg("127.0.0.1:0");859 +    for a in extra_args {860 +        cmd.arg(a);861 +    }862 +    for (k, v) in envs {863 +        cmd.env(k, v);864 +    }865 +    cmd.stdout(Stdio::piped()).stderr(Stdio::piped());866 +    let mut child = cmd.spawn().expect("spawn hu13 serve");867 +    let stdout = child.stdout.take().expect("piped stdout");868 +    let mut lines = BufReader::new(stdout).lines();869 +    let mut addr = String::new();870 +    for _ in 0..50 {871 +        match lines.next() {872 +            Some(Ok(line)) if line.contains("listening on ") => {873 +                addr = line874 +                    .rsplit("listening on ")875 +                    .next()876 +                    .unwrap_or("")877 +                    .trim()878 +                    .to_string();879 +                break;880 +            }881 +            Some(Ok(_)) => continue,882 +            _ => break,883 +        }884 +    }885 +    assert!(!addr.is_empty(), "serve printed no `listening on` line");886 +    assert!(addr.starts_with("127.0.0.1:"), "loopback only, got {addr}");887 +    std::thread::sleep(Duration::from_millis(100));888 +    let srv = Server {889 +        child,890 +        addr,891 +        _www: Some(www),892 +        _inbox_owned: inbox,893 +        inbox_path,894 +    };895 +    // Return a dummy www holder? Caller keeps server alive; second value unused.896 +    (srv, TmpDir::fresh("unused"))897 +}898 +899 +fn start_server(extra_args: &[&str], envs: &HashMap<String, String>) -> Server {900 +    let www = TmpDir::fresh("www");901 +    std::fs::write(902 +        www.path().join("index.html"),903 +        "<!DOCTYPE html><title>hi</title><p>hi</p>",904 +    )905 +    .unwrap();906 +    let inbox = TmpDir::fresh("inbox");907 +    let inbox_path = inbox.path().to_path_buf();908 +    let (srv, _dummy) = start_with(www, Some(inbox), inbox_path, extra_args, envs);909 +    srv910 +}911 +912 +fn no_env() -> HashMap<String, String> {913 +    HashMap::new()914 +}915 +916 +fn transact(addr: &str, req: &[u8]) -> String {917 +    let mut s = TcpStream::connect(addr).expect("connect");918 +    s.set_read_timeout(Some(Duration::from_secs(10))).unwrap();919 +    s.set_write_timeout(Some(Duration::from_secs(10))).unwrap();920 +    s.write_all(req).expect("write");921 +    let mut out = Vec::new();922 +    s.read_to_end(&mut out).expect("read");923 +    String::from_utf8_lossy(&out).into_owned()924 +}925 +926 +fn status_line(resp: &str) -> &str {927 +    resp.split("\r\n").next().unwrap_or("")928 +}929 +930 +fn resp_body(resp: &str) -> &str {931 +    match resp.find("\r\n\r\n") {932 +        Some(i) => &resp[i + 4..],933 +        None => "",934 +    }935 +}936 +937 +fn header_value(resp: &str, name: &str) -> Option<String> {938 +    let want = name.to_ascii_lowercase();939 +    for line in resp.split("\r\n").skip(1) {940 +        if line.is_empty() {941 +            break;942 +        }943 +        if let Some((k, v)) = line.split_once(':') {944 +            if k.trim().to_ascii_lowercase() == want {945 +                return Some(v.trim().to_string());946 +            }947 +        }948 +    }949 +    None950 +}951 +952 +fn assert_post_hygiene(resp: &str) {953 +    let nosniff = header_value(resp, "x-content-type-options").unwrap_or_default();954 +    assert_eq!(955 +        nosniff.to_ascii_lowercase(),956 +        "nosniff",957 +        "POST responses carry nosniff, got: {resp}"958 +    );959 +    let cc = header_value(resp, "cache-control").unwrap_or_default();960 +    assert!(961 +        cc.to_ascii_lowercase().contains("no-store"),962 +        "POST responses carry no-store, got: {resp}"963 +    );964 +    assert!(965 +        header_value(resp, "set-cookie").is_none(),966 +        "no cookies ever, got: {resp}"967 +    );968 +    assert!(969 +        header_value(resp, "access-control-allow-origin").is_none(),970 +        "no CORS ever, got: {resp}"971 +    );972 +}973 +974 +fn enc(s: &str) -> String {975 +    let mut o = String::new();976 +    for b in s.bytes() {977 +        match b {978 +            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {979 +                o.push(b as char);980 +            }981 +            b' ' => o.push('+'),982 +            _ => o.push_str(&format!("%{b:02X}")),983 +        }984 +    }985 +    o986 +}987 +988 +fn post(path: &str, ctype: &str, body: &[u8], extra_headers: &[(&str, &str)]) -> Vec<u8> {989 +    let mut head = format!(990 +        "POST {path} HTTP/1.1\r\nHost: x\r\nContent-Type: {ctype}\r\nContent-Length: {}\r\n",991 +        body.len()992 +    );993 +    for (k, v) in extra_headers {994 +        head.push_str(k);995 +        head.push_str(": ");996 +        head.push_str(v);997 +        head.push_str("\r\n");998 +    }999 +    head.push_str("Connection: close\r\n\r\n");1000 +    head.into_bytes()1001 +        .into_iter()1002 +        .chain(body.iter().copied())1003 +        .collect()1004 +}1005 +1006 +const URLENCODED: &str = "application/x-www-form-urlencoded";1007 +1008 +fn valid_issue(marker: &str) -> Vec<u8> {1009 +    format!(1010 +        "title={}&body={}&license=agree&website=",1011 +        enc(&format!("t {marker}")),1012 +        enc(&format!("b {marker}")),1013 +    )1014 +    .into_bytes()1015 +}1016 +1017 +// ---- abuse: one behavior each ------------------------------------------1018 +1019 +#[test]1020 +fn three_rapid_posts_third_is_429() {1021 +    let srv = start_server(&[], &no_env());1022 +    let mut codes = Vec::new();1023 +    let mut last = String::new();1024 +    let t0 = Instant::now();1025 +    for i in 0..3 {1026 +        let r = transact(1027 +            &srv.addr,1028 +            &post(1029 +                "/inbox/issue",1030 +                URLENCODED,1031 +                &valid_issue(&format!("RATE-{i}")),1032 +                &[],1033 +            ),1034 +        );1035 +        codes.push(status_line(&r).to_string());1036 +        last = r;1037 +    }1038 +    let elapsed = t0.elapsed();1039 +    assert!(codes[0].contains("200"), "1st: {codes:?}");1040 +    assert!(codes[1].contains("200"), "2nd: {codes:?}");1041 +    assert!(codes[2].contains("429"), "3rd is 429: {codes:?}");1042 +    assert_post_hygiene(&last);1043 +    assert!(1044 +        elapsed >= Duration::from_millis(3 * 350),1045 +        "fixed delay on every POST incl. 429 (took {elapsed:?})"1046 +    );1047 +    assert_eq!(srv.inbox_files().len(), 2, "429 stores nothing new");1048 +}1049 +1050 +#[test]1051 +fn quota_full_tiny_quota_is_503() {1052 +    // Isolate quota from rate: generous bucket, tiny quota via flags.1053 +    let srv = start_server(&["--quota", "2", "--rate-per-hour", "100"], &no_env());1054 +    for i in 0..2 {1055 +        let r = transact(1056 +            &srv.addr,1057 +            &post(1058 +                "/inbox/issue",1059 +                URLENCODED,1060 +                &valid_issue(&format!("QUOTA-{i}")),1061 +                &[],1062 +            ),1063 +        );1064 +        assert!(status_line(&r).contains("200"), "fill {i}: {r}");1065 +    }1066 +    assert_eq!(srv.inbox_files().len(), 2);1067 +    let r = transact(1068 +        &srv.addr,1069 +        &post("/inbox/issue", URLENCODED, &valid_issue("QUOTA-FULL"), &[]),1070 +    );1071 +    assert!(1072 +        status_line(&r).contains("503"),1073 +        "quota-full is 503, got: {r}"1074 +    );1075 +    assert_post_hygiene(&r);1076 +    assert_eq!(srv.inbox_files().len(), 2, "503 stores nothing");1077 +}1078 +1079 +#[test]1080 +fn oversize_is_413() {1081 +    let srv = start_server(&[], &no_env());1082 +    let big = "b".repeat(33 * 1024);1083 +    let body = format!(1084 +        "title={}&body={}&license=agree&website=",1085 +        enc("t"),1086 +        enc(&big)1087 +    );1088 +    assert!(body.len() > 32 * 1024);1089 +    let r = transact(1090 +        &srv.addr,1091 +        &post("/inbox/issue", URLENCODED, body.as_bytes(), &[]),1092 +    );1093 +    assert!(1094 +        status_line(&r).contains("413"),1095 +        "got: {}",1096 +        &r[..r.len().min(200)]1097 +    );1098 +    assert_post_hygiene(&r);1099 +    assert!(srv.inbox_files().is_empty());1100 +}1101 +1102 +#[test]1103 +fn multipart_is_400() {1104 +    let srv = start_server(&[], &no_env());1105 +    let body = "--xyz\r\nContent-Disposition: form-data; name=\"title\"\r\n\r\nt\r\n--xyz--\r\n";1106 +    let r = transact(1107 +        &srv.addr,1108 +        &post(1109 +            "/inbox/issue",1110 +            "multipart/form-data; boundary=xyz",1111 +            body.as_bytes(),1112 +            &[],1113 +        ),1114 +    );1115 +    assert!(status_line(&r).contains("400"), "got: {r}");1116 +    assert_post_hygiene(&r);1117 +    assert!(srv.inbox_files().is_empty());1118 +}1119 +1120 +#[test]1121 +fn honeypot_silent_zero_files() {1122 +    let srv = start_server(&[], &no_env());1123 +    let body = format!(1124 +        "title={}&body={}&license=agree&website=bot-probe",1125 +        enc("t"),1126 +        enc("b"),1127 +    );1128 +    let r = transact(1129 +        &srv.addr,1130 +        &post("/inbox/issue", URLENCODED, body.as_bytes(), &[]),1131 +    );1132 +    assert!(status_line(&r).contains("200"), "fake received, got: {r}");1133 +    assert!(resp_body(&r).contains("received"));1134 +    assert_post_hygiene(&r);1135 +    assert!(!r.contains("bot-probe"), "no reflection even on drop");1136 +    assert!(srv.inbox_files().is_empty(), "honeypot stores nothing");1137 +}1138 +1139 +#[test]1140 +fn null_byte_and_non_utf8_are_400() {1141 +    let srv = start_server(&[], &no_env());1142 +    let r1 = transact(1143 +        &srv.addr,1144 +        &post(1145 +            "/inbox/issue",1146 +            URLENCODED,1147 +            b"title=a%00b&body=c&license=agree&website=",1148 +            &[],1149 +        ),1150 +    );1151 +    assert!(status_line(&r1).contains("400"), "nul: {r1}");1152 +    assert_post_hygiene(&r1);1153 +    let mut bad = b"title=t&body=".to_vec();1154 +    bad.extend_from_slice(&[0xff, 0xfe]);1155 +    bad.extend_from_slice(b"&license=agree&website=");1156 +    let r2 = transact(&srv.addr, &post("/inbox/issue", URLENCODED, &bad, &[]));1157 +    assert!(status_line(&r2).contains("400"), "non-utf8: {r2}");1158 +    assert_post_hygiene(&r2);1159 +    assert!(srv.inbox_files().is_empty());1160 +}1161 +1162 +#[test]1163 +fn reflected_xss_probe_contains_no_input_bytes() {1164 +    let srv = start_server(&[], &no_env());1165 +    let marker = format!("XSS-{}-{}", std::process::id(), unique("x").display());1166 +    let evil = format!("<script>alert(1)</script><img src=x onerror=alert(2)> {marker}");1167 +    let body = format!(1168 +        "title={}&body={}&license=agree&website=",1169 +        enc(&evil),1170 +        enc(&evil),1171 +    );1172 +    let r = transact(1173 +        &srv.addr,1174 +        &post("/inbox/issue", URLENCODED, body.as_bytes(), &[]),1175 +    );1176 +    assert!(status_line(&r).contains("200"), "got: {r}");1177 +    assert_post_hygiene(&r);1178 +    assert!(1179 +        !r.contains(&marker),1180 +        "response contains no input bytes (reflected-XSS dead)"1181 +    );1182 +    assert!(!r.contains("<script"), "zero reflection, zero JS");1183 +    // Stored raw (never rendered to HTML over HTTP); file itself is fine.1184 +    assert_eq!(srv.inbox_files().len(), 1);1185 +    let raw = std::fs::read_to_string(&srv.inbox_files()[0]).unwrap();1186 +    assert!(!raw.contains("127.0.0.1"), "no dotted quad on disk");1187 +}1188 +1189 +#[test]1190 +fn restart_persistence_files_survive() {1191 +    let www1 = TmpDir::fresh("www1");1192 +    std::fs::write(www1.path().join("index.html"), "<p>hi</p>").unwrap();1193 +    let inbox_dir = TmpDir::fresh("shared-inbox");1194 +    let inbox_path = inbox_dir.path().to_path_buf();1195 +    let marker = format!("PERSIST-{}", std::process::id());1196 +    let first = {1197 +        let (srv, _d) = start_with(www1, None, inbox_path.clone(), &[], &no_env());1198 +        let r = transact(1199 +            &srv.addr,1200 +            &post("/inbox/issue", URLENCODED, &valid_issue(&marker), &[]),1201 +        );1202 +        assert!(status_line(&r).contains("200"), "first store: {r}");1203 +        assert_eq!(srv.inbox_files().len(), 1);1204 +        std::fs::read_to_string(&srv.inbox_files()[0]).unwrap()1205 +        // srv drops here → first serve instance exits1206 +    };1207 +    assert!(first.contains(&marker));1208 +    // Second serve instance sharing the same inbox dir: file survives.1209 +    let www2 = TmpDir::fresh("www2");1210 +    std::fs::write(www2.path().join("index.html"), "<p>hi</p>").unwrap();1211 +    let (srv2, _d2) = start_with(www2, None, inbox_path.clone(), &[], &no_env());1212 +    let files = srv2.inbox_files();1213 +    assert_eq!(files.len(), 1, "files survive serve restart");1214 +    assert_eq!(std::fs::read_to_string(&files[0]).unwrap(), first);1215 +    // Fresh rate buckets after restart (unlinkable): a new POST still lands.1216 +    let r = transact(1217 +        &srv2.addr,1218 +        &post("/inbox/issue", URLENCODED, &valid_issue("PERSIST-2"), &[]),1219 +    );1220 +    assert!(status_line(&r).contains("200"), "post-restart store: {r}");1221 +    assert_eq!(srv2.inbox_files().len(), 2);1222 +    // `inbox_path` lives in `inbox_dir` (kept alive by the test); servers1223 +    // hold no ownership (`None`) so drop order is safe.1224 +    let _ = &inbox_dir;1225 +}1226 +1227 +#[test]1228 +fn origin_forgery_is_403_without_origin_passes() {1229 +    let srv = start_server(&[], &no_env());1230 +    let evil = transact(1231 +        &srv.addr,1232 +        &post(1233 +            "/inbox/issue",1234 +            URLENCODED,1235 +            &valid_issue("ORIGIN-EVIL"),1236 +            &[("Origin", "https://evil.example"), ("Host", "x")],1237 +        ),1238 +    );1239 +    assert!(status_line(&evil).contains("403"), "forgery: {evil}");1240 +    assert_post_hygiene(&evil);1241 +    assert!(!evil.contains("evil.example"), "no echo of Origin");1242 +    assert!(srv.inbox_files().is_empty());1243 +    let ok = transact(1244 +        &srv.addr,1245 +        &post("/inbox/issue", URLENCODED, &valid_issue("ORIGIN-OK"), &[]),1246 +    );1247 +    assert!(1248 +        status_line(&ok).contains("200"),1249 +        "curl (no Origin) passes: {ok}"1250 +    );1251 +}1252 +1253 +#[test]1254 +fn caps_tunable_via_env() {1255 +    // HU13_MAX_TITLE=5 via env: a 6-char title must fail where default passes.1256 +    let mut env = HashMap::new();1257 +    env.insert("HU13_MAX_TITLE".to_string(), "5".to_string());1258 +    let srv = start_server(&[], &env);1259 +    let body = format!(1260 +        "title={}&body={}&license=agree&website=",1261 +        enc("123456"),1262 +        enc("b")1263 +    );1264 +    let r = transact(1265 +        &srv.addr,1266 +        &post("/inbox/issue", URLENCODED, body.as_bytes(), &[]),1267 +    );1268 +    assert!(1269 +        status_line(&r).contains("400"),1270 +        "tuned cap enforced, got: {r}"1271 +    );1272 +    assert!(srv.inbox_files().is_empty());1273 +}1274 -- 1275 2.55.01276