CB-WP-0012-T04: cb-render-html — stage 1 draws, and the browser is the toolkit

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>
This commit is contained in:
tegwick 2026-08-02 04:27:25 +02:00
parent 883b608f36
commit 84d688688d
14 changed files with 2058 additions and 2 deletions

View file

@ -0,0 +1,323 @@
//! The loopback listener, and the controls that make it safe to have one.
//!
//! ADR-0007 Decision 5, controls 14. 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 13 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");
}
}