324 lines
12 KiB
Rust
324 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");
|
|||
|
|
}
|
|||
|
|
}
|