collective

Browse / github.com/storytold/gridcraft / patch

Security: control channel enforces its token deadline and caps waiting connections (#2717)

Control channel: pre-auth timeout is set on the wrong socket handle, and waiting connections are uncapped ## Summary With `--control`, a connection that hasn't sent the token is meant to be closed after 10 s (`AUTH_TIMEOUT`, `docs/control-protocol.md`). On Windows it never is. And there's still no cap on how many connections can be waiting to authenticate. **Location:** `apps/gridcraft/src/control_server.rs` - `serve` reads through `BufReader::new(stream.try_clone())`, but `authenticate` sets the timeout on `out`, the original stream (`out.set_read_timeout(Some(AUTH_TIMEOUT))`). On Windows the clone is a separate socket handle with its own timeout, so the auth read is unbounded. - Even where the

Status
open
Score
0.00
Adopters
0 other operators
Author
Higgs32584 (imported from pull request #2722, open)
Base
45f5ab18e4e57bf50ee88d2526deb4b87c5b24cb
Size
+166 −16 in 2 files
Review
pending
Shared
2026-10-28
Id
6e26aebbebf534b1cc6c98da

How it went

No other operator has reported using it yet.

Diff

raw · apps/gridcraft/src/control_server.rs docs/control-protocol.md

diff --git a/apps/gridcraft/src/control_server.rs b/apps/gridcraft/src/control_server.rs--- a/apps/gridcraft/src/control_server.rs+++ b/apps/gridcraft/src/control_server.rs@@ -12,16 +12,47 @@ use std::io::{BufRead, BufReader, Read, Write}; use std::net::{TcpListener, TcpStream}; use std::sync::Arc;+use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::mpsc::{Receiver, Sender, channel};-use std::time::Duration;+use std::time::{Duration, Instant};  use gridcraft_mcp::control_auth; use gridcraft_ui_egui::ControlRequest; use serde_json::{Value as Json, json};  const MAX_LINE: usize = 64 * 1024 * 1024;-/// How long a new connection may take to send its token.+/// How long a new connection may take to send its token, in total. const AUTH_TIMEOUT: Duration = Duration::from_secs(10);+/// How many connections may be waiting to authenticate at once. Anyone on the machine can+/// connect without the token, so past this a new connection is closed before anything is read:+/// each waiting one holds a thread and a socket.+const MAX_PENDING_AUTH: usize = 16;++/// How the server treats connections that haven't authenticated yet.+#[derive(Clone, Copy)]+struct AuthLimits {+    timeout: Duration,+    max_pending: usize,+}++const AUTH_LIMITS: AuthLimits = AuthLimits { timeout: AUTH_TIMEOUT, max_pending: MAX_PENDING_AUTH };++/// A connection that is still waiting to authenticate; dropping it frees its place.+struct Pending(Arc<AtomicUsize>);++impl Pending {+    /// A place among the `max` waiting connections, or `None` when they are all taken.+    fn claim(waiting: &Arc<AtomicUsize>, max: usize) -> Option<Pending> {+        waiting.try_update(Ordering::AcqRel, Ordering::Acquire, |n| (n < max).then_some(n + 1)).ok()?;+        Some(Pending(Arc::clone(waiting)))+    }+}++impl Drop for Pending {+    fn drop(&mut self) {+        self.0.fetch_sub(1, Ordering::AcqRel);+    }+}  /// The secret of this run: the one the user chose (`--control-token`, `GRIDCRAFT_CONTROL_TOKEN`), /// else 256 random bits from the operating system, written to a file only the user can read.@@ -51,35 +82,75 @@ pub fn start(port: u16, chosen_token: Option<String>, ctx: egui::Context) -> std  /// Accept connections on `listener`; requests arrive on the returned receiver. fn serve_listener(listener: TcpListener, token: Arc<str>, ctx: egui::Context) -> std::io::Result<Receiver<ControlRequest>> {+    serve_listener_with(listener, token, ctx, AUTH_LIMITS)+}++fn serve_listener_with(listener: TcpListener, token: Arc<str>, ctx: egui::Context, limits: AuthLimits) -> std::io::Result<Receiver<ControlRequest>> {     let (tx, rx) = channel();+    let waiting = Arc::new(AtomicUsize::new(0));     std::thread::Builder::new().name("control".into()).spawn(move || {-        for stream in listener.incoming().flatten() {+        for stream in listener.incoming() {+            let Ok(stream) = stream else {+                // Out of sockets or handles: wait for some to free instead of spinning on the error.+                std::thread::sleep(Duration::from_millis(50));+                continue;+            };+            // Dropping the stream closes it before anything is read.+            let Some(place) = Pending::claim(&waiting, limits.max_pending) else { continue };             let tx = tx.clone();             let ctx = ctx.clone();             let token = Arc::clone(&token);-            let _ = std::thread::Builder::new().name("control-conn".into()).spawn(move || serve(stream, &token, tx, ctx));+            let _ =+                std::thread::Builder::new().name("control-conn".into()).spawn(move || serve(stream, &token, tx, ctx, limits.timeout, Some(place)));         }     })?;     Ok(rx) }  /// Read the connection's first line (at most [`control_auth::MAX_AUTH_LINE`] bytes, within-/// [`AUTH_TIMEOUT`]) and check the token. `true` when the peer may go on.-fn authenticate(reader: &mut BufReader<TcpStream>, out: &mut TcpStream, token: &str) -> bool {-    let _ = out.set_read_timeout(Some(AUTH_TIMEOUT));-    let mut first = String::new();-    let read = reader.by_ref().take(control_auth::MAX_AUTH_LINE as u64).read_line(&mut first);-    let verdict = match read {-        Ok(n) if n > 0 && first.ends_with('\n') => control_auth::check_first_line(&first, token),+/// `timeout` in total) and check the token. `true` when the peer may go on.+#[allow(clippy::disallowed_methods)] // the desktop app (native only): wasm never runs it+fn authenticate(reader: &mut BufReader<TcpStream>, out: &mut TcpStream, token: &str, timeout: Duration) -> bool {+    let deadline = Instant::now().checked_add(timeout).unwrap_or_else(Instant::now);+    let mut first = Vec::new();+    let read = read_auth_line(reader, &mut first, deadline);+    let verdict = match (read, std::str::from_utf8(&first)) {+        (Ok(()), Ok(line)) if line.ends_with('\n') => control_auth::check_first_line(line, token),         _ => Err(json!({"ok": false, "error": "authentication required"})),     };     let allowed = verdict.is_ok();     let reply = verdict.unwrap_or_else(|e| e);     let _ = writeln!(out, "{reply}");-    let _ = out.set_read_timeout(None);+    let _ = reader.get_ref().set_read_timeout(None);     allowed } +/// Read the first line, at most [`control_auth::MAX_AUTH_LINE`] bytes, by `deadline`.+///+/// The timeout goes on the handle the reader reads: on Windows a `try_clone` of the stream keeps+/// its own, so one set on the writing half never fired (a silent peer held its thread forever).+/// It is shortened before every read, so a peer sending one byte at a time can't stretch it.+#[allow(clippy::disallowed_methods)] // the desktop app (native only): wasm never runs it+fn read_auth_line(reader: &mut BufReader<TcpStream>, line: &mut Vec<u8>, deadline: Instant) -> std::io::Result<()> {+    let max = control_auth::MAX_AUTH_LINE;+    while line.len() < max && line.last() != Some(&b'\n') {+        let left = deadline.saturating_duration_since(Instant::now());+        if left.is_zero() {+            return Err(std::io::ErrorKind::TimedOut.into());+        }+        reader.get_ref().set_read_timeout(Some(left))?;+        let buf = reader.fill_buf()?;+        if buf.is_empty() {+            break;+        }+        let room = max.saturating_sub(line.len());+        let take = buf.iter().take(room).position(|b| *b == b'\n').map_or(buf.len().min(room), |i| i + 1);+        line.extend(buf.iter().take(take));+        reader.consume(take);+    }+    Ok(())+}+ /// Read one `\n`-terminated line of at most `max` bytes into `buf` (newline stripped). /// `Ok(false)` at end of stream; `Err` when the line is too long or the read fails. fn read_bounded_line(reader: &mut impl BufRead, buf: &mut Vec<u8>, max: usize) -> Result<bool, String> {@@ -148,11 +219,14 @@ fn is_http_request_line(text: &str) -> bool {         || text.rsplit_once(' ').is_some_and(|(_, version)| version.starts_with("HTTP/")) } -fn serve(stream: TcpStream, token: &str, tx: Sender<ControlRequest>, ctx: egui::Context) {+fn serve(stream: TcpStream, token: &str, tx: Sender<ControlRequest>, ctx: egui::Context, auth_timeout: Duration, place: Option<Pending>) {     let Ok(read) = stream.try_clone() else { return };     let mut out = stream;     let mut reader = BufReader::new(read);-    if !authenticate(&mut reader, &mut out, token) {+    let allowed = authenticate(&mut reader, &mut out, token, auth_timeout);+    // Authenticated or refused, the connection no longer counts as waiting.+    drop(place);+    if !allowed {         return;     }     let mut buf = Vec::new();@@ -294,7 +368,7 @@ mod tests {         std::thread::spawn(move || {             for stream in listener.incoming().flatten() {                 let tx = tx.clone();-                std::thread::spawn(move || serve(stream, TOKEN, tx, egui::Context::default()));+                std::thread::spawn(move || serve(stream, TOKEN, tx, egui::Context::default(), AUTH_TIMEOUT, None));             }         });         addr@@ -346,6 +420,81 @@ mod tests {         assert_eq!(got[1]["ok"], json!(false));     } +    /// Whether the server closed `c` (end of stream, or a refusal and then end of stream) within+    /// `within`, and how long it took.+    #[allow(clippy::disallowed_methods)] // a native-only test of the desktop app+    fn closed_within(c: TcpStream, within: Duration) -> (bool, Duration) {+        let start = Instant::now();+        c.set_read_timeout(Some(within)).unwrap();+        let mut r = BufReader::new(c);+        let mut line = String::new();+        loop {+            line.clear();+            match r.read_line(&mut line) {+                Ok(0) => return (true, start.elapsed()),+                Ok(_) => continue,+                Err(e) if matches!(e.kind(), std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut) => return (false, start.elapsed()),+                Err(_) => return (true, start.elapsed()),+            }+        }+    }++    /// The token deadline is for the whole first line: neither a silent peer nor one sending a+    /// byte at a time keeps the connection (and its thread) past it.+    #[test]+    fn a_silent_or_slow_peer_is_closed_at_the_auth_deadline() {+        let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();+        let addr = listener.local_addr().unwrap();+        let limits = AuthLimits { timeout: Duration::from_millis(300), max_pending: MAX_PENDING_AUTH };+        let _rx = serve_listener_with(listener, TOKEN.into(), egui::Context::default(), limits).unwrap();++        let silent = TcpStream::connect(addr).unwrap();+        let mut slow = TcpStream::connect(addr).unwrap();+        let mut drip = slow.try_clone().unwrap();+        std::thread::spawn(move || {+            for b in control_auth::auth_line(1, TOKEN).bytes() {+                if drip.write_all(&[b]).is_err() {+                    break;+                }+                std::thread::sleep(Duration::from_millis(50));+            }+        });+        let (closed, took) = closed_within(silent, Duration::from_secs(5));+        assert!(closed && took < Duration::from_secs(3), "silent peer kept for {took:?}");+        let (closed, took) = closed_within(slow.try_clone().unwrap(), Duration::from_secs(5));+        assert!(closed && took < Duration::from_secs(3), "byte-at-a-time peer kept for {took:?}");+        let _ = slow.flush();+    }++    /// Peers without the token can't pile up threads: past the cap a new connection is closed at+    /// once, and a waiting one that authenticates frees its place.+    #[test]+    fn connections_waiting_to_authenticate_are_capped() {+        let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();+        let addr = listener.local_addr().unwrap();+        let limits = AuthLimits { timeout: Duration::from_secs(20), max_pending: 2 };+        let _rx = serve_listener_with(listener, TOKEN.into(), egui::Context::default(), limits).unwrap();++        let mut first = TcpStream::connect(addr).unwrap();+        let _second = TcpStream::connect(addr).unwrap();+        std::thread::sleep(Duration::from_millis(200));+        let (closed, took) = closed_within(TcpStream::connect(addr).unwrap(), Duration::from_secs(5));+        assert!(closed && took < Duration::from_secs(3), "a third waiting connection was kept");++        first.set_read_timeout(Some(Duration::from_secs(5))).unwrap();+        first.write_all(format!("{}\n", control_auth::auth_line(1, TOKEN)).as_bytes()).unwrap();+        let mut reply = String::new();+        BufReader::new(first.try_clone().unwrap()).read_line(&mut reply).unwrap();+        assert_eq!(serde_json::from_str::<Json>(&reply).unwrap()["ok"], json!(true), "{reply}");+        std::thread::sleep(Duration::from_millis(200));+        let mut next = TcpStream::connect(addr).unwrap();+        next.set_read_timeout(Some(Duration::from_secs(5))).unwrap();+        next.write_all(format!("{}\n", control_auth::auth_line(1, TOKEN)).as_bytes()).unwrap();+        let mut reply = String::new();+        BufReader::new(next).read_line(&mut reply).unwrap();+        assert_eq!(serde_json::from_str::<Json>(&reply).unwrap()["ok"], json!(true), "a freed place was not reused: {reply}");+    }+     #[test]     fn a_chosen_token_must_be_long_enough() {         assert!(make_token(1, Some("short".into())).is_err());diff --git a/docs/control-protocol.md b/docs/control-protocol.md--- a/docs/control-protocol.md+++ b/docs/control-protocol.md@@ -23,7 +23,8 @@ The control port is reachable by every program on the machine, and by a web page   `{"id":0,"ok":true,"result":{"authenticated":true}}`. Any other first line (a wrong or missing   token, another method, text that isn't JSON such as an HTTP request, no line within 10 s, or   one longer than 4 KiB) gets `{"ok":false,"error":"authentication required"}` and the-  connection is closed. After that, a line that isn't JSON also closes the connection.+  connection is closed. At most 16 connections may be waiting to send their token at once; past that a new+  connection is closed before anything is read. After that, a line that isn't JSON also closes the connection. - `gridcraft-cli send`/`mcp --connect` and `gridcraft_mcp::Remote` do this themselves: they use   `GRIDCRAFT_CONTROL_TOKEN` if set, else the token file of that port (so they work for the same   user on the same machine without setup). Your own client reads the file or the variable.