Delivers ADR-0007 Decision 1: visualization, drag-to-propose and hot-seat
play, at a measured marginal AM-4a cost of zero.
games-ground shipped: 23 third-party crates
cb-render-html: 23 third-party crates
new crates introduced: 0
Measured, not asserted — the survey's own lesson. AM-4a is unmoved at
246,250; own source is 7,636 -> 9,652.
What shipped:
crates/cb-render-html doc.rs (HTML/SVG emission, incl. the relationship
graph), input.rs (pointer facts -> commands),
serve.rs (Guard, Request, loopback bind)
tools/cb-play hotseat.rs + `--serve PORT`
Per ADR-0007 Decision 2 there is NO cb-render-api and NO cb-render-null.
The renderer targets the existing Project trait; the port waits for
stage 2's wgpu implementation to be its second use.
The six controls, all live, all mutation-checked (8 mutations, each red
for its stated reason):
1-3 token / Origin+Sec-Fetch-Site / explicit 127.0.0.1 bind
4 a token-less request is refused, in the unit AND over a real socket
5 JS may not construct commands — the page reports pointer facts, Rust
resolves them against the legal list the aggregate already offered,
and a test asserts the emitted script contains no game vocabulary
6 the coverage gate crosses the language boundary: it walks the
serialized view for leaf paths and requires each token to appear in
the PARSED emitted document, with a test that the parse really is a
parse (script/style contents must not count as rendered)
The gate fired on its author again, on its first run: ground_choices.*.
choice, ground_choices.*.problem and players.*.blame_from were in neither
list. The last is the one worth keeping — an EMPTY vector is a leaf path
of its own, and it now renders as an explicit absence.
Also, a mutation that did not go red: removing the Sec-Fetch-Site arm
alone left the cross-site test green, because the Origin check caught it
independently. Both had to be removed before the control bit. Recorded
because a control that passes for a reason you did not intend has not
been demonstrated.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
323 lines
12 KiB
Rust
323 lines
12 KiB
Rust
//! 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<String>,
|
||
pub origin: Option<String>,
|
||
pub sec_fetch_site: Option<String>,
|
||
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<Self, Refusal> {
|
||
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<String>, 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> {
|
||
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");
|
||
}
|
||
}
|