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