clay-borg/crates/cb-render-html/src/serve.rs
tegwick 84d688688d 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>
2026-08-02 04:27:25 +02:00

323 lines
12 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! 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");
}
}