//! The loopback listener, and the controls that make it safe to have one. //! //! ADR-0007 Decision 5, controls 1–4. The threat the review named is not //! hypothetical: //! //! > A loopback listener is reachable by any process on the machine **and //! > by any web page the user visits**. The browser running the table is //! > the same browser reading the internet — that is the whole premise of //! > the option. An unauthenticated endpoint accepting `POST /command` and //! > mutating authoritative game state is a remote-controlled game from //! > any tab the user has open. //! //! So: a per-process token no page can guess, an `Origin` / //! `Sec-Fetch-Site` check that rejects by default, an explicit //! `127.0.0.1` bind, and — the control that makes the other three //! evidence rather than claims — a test that a token-less request is //! refused, with a mutation that turns it red. //! //! [`Guard::admit`] is a pure function of a parsed request, so all of that //! is testable with no socket, no browser, and no timing. use std::io::Read; /// Why a request was refused. Each variant is a control that fired. #[derive(Debug, Clone, PartialEq, Eq)] pub enum Refusal { /// Control 1: no token at all. MissingToken, /// Control 1: a token, but not ours. BadToken, /// Control 2: the request came from somewhere else's page. CrossSite(String), /// Not a shape this endpoint serves. BadRequest(String), } impl std::fmt::Display for Refusal { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Refusal::MissingToken => write!(f, "refused: no session token"), Refusal::BadToken => write!(f, "refused: wrong session token"), Refusal::CrossSite(o) => write!(f, "refused: cross-site request from {o}"), Refusal::BadRequest(m) => write!(f, "refused: {m}"), } } } /// A parsed HTTP/1.1 request, reduced to the fields the controls need. #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct Request { pub method: String, pub path: String, pub token: Option, pub origin: Option, pub sec_fetch_site: Option, pub body: String, } impl Request { /// Parse a raw request. Deliberately strict: this is hand-rolled HTTP /// on a socket a hostile page can reach, so anything unexpected is a /// refusal rather than a best effort. pub fn parse(raw: &str) -> Result { let (head, body) = raw.split_once("\r\n\r\n").unwrap_or((raw, "")); let mut lines = head.split("\r\n"); let start = lines .next() .ok_or_else(|| Refusal::BadRequest("empty request".into()))?; let mut parts = start.split(' '); let method = parts .next() .ok_or_else(|| Refusal::BadRequest("no method".into()))? .to_string(); let target = parts .next() .ok_or_else(|| Refusal::BadRequest("no target".into()))?; let (path, query) = target.split_once('?').unwrap_or((target, "")); let token = query.split('&').find_map(|kv| { kv.strip_prefix("t=") .filter(|v| !v.is_empty()) .map(str::to_string) }); let mut req = Request { method, path: path.to_string(), token, body: body.to_string(), ..Default::default() }; for line in lines { if let Some((k, v)) = line.split_once(':') { let v = v.trim().to_string(); match k.to_ascii_lowercase().as_str() { "origin" => req.origin = Some(v), "sec-fetch-site" => req.sec_fetch_site = Some(v), _ => {} } } } Ok(req) } } /// Holds the per-process token and applies controls 1 and 2. #[derive(Debug, Clone)] pub struct Guard { token: String, origin: String, } impl Guard { pub fn new(token: impl Into, port: u16) -> Self { Self { token: token.into(), origin: format!("http://127.0.0.1:{port}"), } } /// Mint an unguessable token. /// /// `/dev/urandom` where it exists; otherwise `RandomState`, whose /// per-process seed the OS randomises. The fallback is weaker and is /// named rather than hidden — a token that silently degrades to /// something predictable is worse than no token, because the controls /// would still report themselves as passing. pub fn mint(port: u16) -> Self { let mut buf = [0u8; 24]; let strong = std::fs::File::open("/dev/urandom") .and_then(|mut f| f.read_exact(&mut buf)) .is_ok(); if !strong { use std::hash::{BuildHasher, Hasher}; let s = std::collections::hash_map::RandomState::new(); for chunk in buf.chunks_mut(8) { let mut h = s.build_hasher(); h.write_usize(std::process::id() as usize); h.write_u128( std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_nanos()) .unwrap_or(0), ); chunk.copy_from_slice(&h.finish().to_le_bytes()[..chunk.len()]); } } let token: String = buf.iter().map(|b| format!("{b:02x}")).collect(); Self::new(token, port) } pub fn token(&self) -> &str { &self.token } /// The URL the page is told to POST to. The page cannot mint one. pub fn endpoint(&self) -> String { format!("/command?t={}", self.token) } pub fn page_url(&self) -> String { format!("{}/?t={}", self.origin, self.token) } /// Apply controls 1 and 2. Rejects by default. pub fn admit(&self, req: &Request) -> Result<(), Refusal> { // Control 2 first: a cross-site request should be refused before // its token is even considered, so a leaked token is not enough on // its own. match req.sec_fetch_site.as_deref() { // Sent by every modern browser. Anything but same-origin is // not our page. Some("same-origin") | Some("none") => {} Some(other) => return Err(Refusal::CrossSite(other.to_string())), None => { // Older or non-browser clients omit it; fall back to // Origin, and require it to be ours when present. if let Some(o) = &req.origin { if o != &self.origin { return Err(Refusal::CrossSite(o.clone())); } } } } if let Some(o) = &req.origin { if o != &self.origin { return Err(Refusal::CrossSite(o.clone())); } } // Control 1. match &req.token { None => Err(Refusal::MissingToken), Some(t) if constant_eq(t, &self.token) => Ok(()), Some(_) => Err(Refusal::BadToken), } } } /// Compare without an early return on the first differing byte. /// /// The endpoint is loopback and the token is 192 bits, so a timing oracle /// is not the realistic attack here. It costs one line. fn constant_eq(a: &str, b: &str) -> bool { if a.len() != b.len() { return false; } a.bytes() .zip(b.bytes()) .fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0 } /// Control 3: bind explicitly to loopback, never `0.0.0.0`. pub fn bind(port: u16) -> std::io::Result { std::net::TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, port)) } #[cfg(test)] mod tests { use super::*; fn guard() -> Guard { Guard::new("s3cr3t", 8731) } fn req(raw: &str) -> Request { Request::parse(raw).expect("parses") } /// **ADR-0007 control 4.** The one that makes controls 1–3 evidence /// rather than claims. /// /// M-D1-MUT: delete the `None => Err(Refusal::MissingToken)` arm in /// `admit` (return `Ok(())` instead) and this goes red with /// `a token-less request was admitted`. Run 2026-08-02. #[test] fn a_token_less_request_is_refused() { let g = guard(); let r = req("POST /command HTTP/1.1\r\nSec-Fetch-Site: same-origin\r\n\r\ndown=a&up=b"); assert_eq!( g.admit(&r), Err(Refusal::MissingToken), "a token-less request was admitted" ); } #[test] fn a_wrong_token_is_refused() { let g = guard(); let r = req("POST /command?t=guess HTTP/1.1\r\nSec-Fetch-Site: same-origin\r\n\r\n"); assert_eq!(g.admit(&r), Err(Refusal::BadToken)); // A prefix of the real token must not be admitted either. let r = req("POST /command?t=s3c HTTP/1.1\r\nSec-Fetch-Site: same-origin\r\n\r\n"); assert_eq!(g.admit(&r), Err(Refusal::BadToken)); } #[test] fn the_right_token_from_our_own_page_is_admitted() { let g = guard(); let r = req( "POST /command?t=s3cr3t HTTP/1.1\r\nSec-Fetch-Site: same-origin\r\n\ Origin: http://127.0.0.1:8731\r\n\r\ndown=a&up=b", ); assert_eq!(g.admit(&r), Ok(())); } /// The attack the review named: a page on the open internet POSTs to /// `127.0.0.1`. Even holding a leaked token, it must not be admitted. #[test] fn a_cross_site_request_is_refused_even_with_the_right_token() { let g = guard(); let r = req( "POST /command?t=s3cr3t HTTP/1.1\r\nSec-Fetch-Site: cross-site\r\n\ Origin: https://evil.example\r\n\r\ndown=a&up=b", ); assert!( matches!(g.admit(&r), Err(Refusal::CrossSite(_))), "{:?}", g.admit(&r) ); // A client that omits Sec-Fetch-Site but sends a foreign Origin is // the same attack with an older browser. let r = req( "POST /command?t=s3cr3t HTTP/1.1\r\nOrigin: https://evil.example\r\n\r\ndown=a&up=b", ); assert!(matches!(g.admit(&r), Err(Refusal::CrossSite(_)))); } #[test] fn requests_parse_and_carry_what_the_controls_need() { let r = req("POST /command?t=abc HTTP/1.1\r\nHost: 127.0.0.1:8731\r\n\ Origin: http://127.0.0.1:8731\r\nSec-Fetch-Site: same-origin\r\n\ Content-Length: 9\r\n\r\ndown=a&up=b"); assert_eq!(r.method, "POST"); assert_eq!(r.path, "/command"); assert_eq!(r.token.as_deref(), Some("abc")); assert_eq!(r.origin.as_deref(), Some("http://127.0.0.1:8731")); assert_eq!(r.sec_fetch_site.as_deref(), Some("same-origin")); assert_eq!(r.body, "down=a&up=b"); // An empty t= is no token, not a token that happens to be empty. let r = req("GET /?t= HTTP/1.1\r\n\r\n"); assert_eq!(r.token, None); } /// A minted token must not be a constant, and must not be short. #[test] fn a_minted_token_is_long_and_not_reused() { let a = Guard::mint(1).token().to_string(); let b = Guard::mint(1).token().to_string(); assert_eq!(a.len(), 48, "token is {} hex chars", a.len()); assert_ne!(a, b, "two mints produced the same token"); assert!(a.chars().all(|c| c.is_ascii_hexdigit())); } /// Control 3, asserted rather than commented. #[test] fn the_listener_binds_loopback_only() { let l = bind(0).expect("bind"); assert_eq!(l.local_addr().unwrap().ip().to_string(), "127.0.0.1"); } }