mirror of
https://github.com/storytold/gridcraft.git
synced 2026-10-26 11:46:49 -06:00
Security: the --control server requires an auth token and closes bad connections (#777)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5.5
parent
fc45b7f6d8
commit
fef1f4ddb1
@@ -27,6 +27,9 @@ winresource = "0.1"
|
||||
craft-print = { git = "https://github.com/storytold/craft-libs", rev = "d27646a5f7c8b4fc85d9dd3213b8c88b58e0088d" }
|
||||
rfd = { workspace = true }
|
||||
arboard = { workspace = true }
|
||||
gridcraft-mcp = { workspace = true }
|
||||
# The control channel's token (OS random numbers); already in the tree.
|
||||
getrandom = "0.4"
|
||||
|
||||
[target.'cfg(any(target_os = "linux", target_os = "freebsd"))'.dependencies]
|
||||
arboard = { workspace = true, features = ["wayland-data-control"] }
|
||||
|
||||
@@ -1,34 +1,82 @@
|
||||
//! Localhost JSON-lines control server. Each connection may send many requests; each line
|
||||
//! `{"id":…, "method":…, "params":…}` gets one reply line `{"id":…, "ok":…, …}`.
|
||||
//!
|
||||
//! The first line of a connection must authenticate with the app's token (#777; see
|
||||
//! `gridcraft_mcp::control_auth`). A connection that doesn't, or later sends a line that isn't
|
||||
//! JSON, is answered once and closed.
|
||||
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::io::{BufRead, BufReader, Read, Write};
|
||||
use std::net::{TcpListener, TcpStream};
|
||||
use std::sync::Arc;
|
||||
use std::sync::mpsc::{Receiver, Sender, channel};
|
||||
use std::time::Duration;
|
||||
|
||||
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.
|
||||
const AUTH_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
|
||||
pub fn start(port: u16, ctx: egui::Context) -> std::io::Result<Receiver<ControlRequest>> {
|
||||
/// 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.
|
||||
fn make_token(port: u16, chosen: Option<String>) -> std::io::Result<String> {
|
||||
if let Some(t) = chosen.map(|t| t.trim().to_string()).filter(|t| !t.is_empty()) {
|
||||
if t.len() < control_auth::MIN_TOKEN_LEN {
|
||||
return Err(std::io::Error::other(format!("the control token must have at least {} characters", control_auth::MIN_TOKEN_LEN)));
|
||||
}
|
||||
eprintln!("gridcraft: control channel on 127.0.0.1:{port} requires the token you set (clients: GRIDCRAFT_CONTROL_TOKEN)");
|
||||
return Ok(t);
|
||||
}
|
||||
let mut bytes = [0u8; 32];
|
||||
getrandom::fill(&mut bytes).map_err(|e| std::io::Error::other(format!("no random numbers for the control token: {e}")))?;
|
||||
let token = control_auth::hex(&bytes);
|
||||
let path = control_auth::write_token_file(port, &token)?;
|
||||
eprintln!("gridcraft: control channel on 127.0.0.1:{port}; its token is in {} (gridcraft-cli reads it)", path.display());
|
||||
Ok(token)
|
||||
}
|
||||
|
||||
pub fn start(port: u16, chosen_token: Option<String>, ctx: egui::Context) -> std::io::Result<Receiver<ControlRequest>> {
|
||||
let listener = TcpListener::bind(("127.0.0.1", port))?;
|
||||
let token: Arc<str> = make_token(port, chosen_token)?.into();
|
||||
let (tx, rx) = channel();
|
||||
std::thread::Builder::new().name("control".into()).spawn(move || {
|
||||
for stream in listener.incoming().flatten() {
|
||||
let tx = tx.clone();
|
||||
let ctx = ctx.clone();
|
||||
let _ = std::thread::Builder::new().name("control-conn".into()).spawn(move || serve(stream, tx, ctx));
|
||||
let token = Arc::clone(&token);
|
||||
let _ = std::thread::Builder::new().name("control-conn".into()).spawn(move || serve(stream, &token, tx, ctx));
|
||||
}
|
||||
})?;
|
||||
log::info!("control channel listening on 127.0.0.1:{port}");
|
||||
Ok(rx)
|
||||
}
|
||||
|
||||
fn serve(stream: TcpStream, tx: Sender<ControlRequest>, ctx: egui::Context) {
|
||||
/// 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),
|
||||
_ => 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);
|
||||
allowed
|
||||
}
|
||||
|
||||
fn serve(stream: TcpStream, token: &str, tx: Sender<ControlRequest>, ctx: egui::Context) {
|
||||
let Ok(read) = stream.try_clone() else { return };
|
||||
let mut out = stream;
|
||||
let reader = BufReader::new(read);
|
||||
let mut reader = BufReader::new(read);
|
||||
if !authenticate(&mut reader, &mut out, token) {
|
||||
return;
|
||||
}
|
||||
for line in reader.lines() {
|
||||
let Ok(line) = line else { break };
|
||||
if line.trim().is_empty() {
|
||||
@@ -42,7 +90,7 @@ fn serve(stream: TcpStream, tx: Sender<ControlRequest>, ctx: egui::Context) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
let _ = writeln!(out, "{}", json!({"ok": false, "error": format!("invalid JSON: {e}")}));
|
||||
continue;
|
||||
break; // Not a client of ours; don't keep reading.
|
||||
}
|
||||
};
|
||||
let id = msg.get("id").cloned().unwrap_or(Json::Null);
|
||||
@@ -65,3 +113,82 @@ fn serve(stream: TcpStream, tx: Sender<ControlRequest>, ctx: egui::Context) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used)]
|
||||
use super::*;
|
||||
|
||||
const TOKEN: &str = "0123456789abcdef0123456789abcdef";
|
||||
|
||||
/// A server on a loopback port with a stand-in UI that echoes each method.
|
||||
fn spawn() -> std::net::SocketAddr {
|
||||
let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let (tx, rx) = channel::<ControlRequest>();
|
||||
std::thread::spawn(move || {
|
||||
for req in rx {
|
||||
let _ = req.reply.send(json!({"ok": true, "result": req.method}));
|
||||
}
|
||||
});
|
||||
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()));
|
||||
}
|
||||
});
|
||||
addr
|
||||
}
|
||||
|
||||
fn lines(addr: std::net::SocketAddr, send: &str) -> Vec<Json> {
|
||||
let mut c = TcpStream::connect(addr).unwrap();
|
||||
c.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
|
||||
c.write_all(send.as_bytes()).unwrap();
|
||||
// Replies up to the server closing the connection.
|
||||
BufReader::new(c).lines().map_while(Result::ok).map(|l| serde_json::from_str(&l).unwrap()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_token_opens_the_connection() {
|
||||
let addr = spawn();
|
||||
let auth = control_auth::auth_line(1, TOKEN);
|
||||
let mut c = TcpStream::connect(addr).unwrap();
|
||||
c.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
|
||||
c.write_all(format!("{auth}\n{{\"id\":2,\"method\":\"ping\"}}\n").as_bytes()).unwrap();
|
||||
let mut r = BufReader::new(c);
|
||||
let mut line = String::new();
|
||||
r.read_line(&mut line).unwrap();
|
||||
assert_eq!(serde_json::from_str::<Json>(&line).unwrap()["ok"], json!(true));
|
||||
line.clear();
|
||||
r.read_line(&mut line).unwrap();
|
||||
let v: Json = serde_json::from_str(&line).unwrap();
|
||||
assert_eq!((v["id"].clone(), v["result"].clone()), (json!(2), json!("ping")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn anything_but_the_token_first_closes_the_connection() {
|
||||
let addr = spawn();
|
||||
let wrong = control_auth::auth_line(1, "ffffffffffffffffffffffffffffffff");
|
||||
for first in [wrong.as_str(), "{\"id\":1,\"method\":\"app.save\",\"params\":{\"path\":\"/tmp/x\"}}", "POST / HTTP/1.1", ""] {
|
||||
// The second request is never served: one refusal, then end of stream.
|
||||
let got = lines(addr, &format!("{first}\n{{\"id\":2,\"method\":\"ping\"}}\n"));
|
||||
assert_eq!(got.len(), 1, "{first}: {got:?}");
|
||||
assert_eq!(got[0]["ok"], json!(false), "{first}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unparsable_line_after_auth_closes_the_connection() {
|
||||
let addr = spawn();
|
||||
let auth = control_auth::auth_line(1, TOKEN);
|
||||
let got = lines(addr, &format!("{auth}\nnot json\n{{\"id\":3,\"method\":\"ping\"}}\n"));
|
||||
assert_eq!(got.len(), 2, "{got:?}");
|
||||
assert_eq!(got[1]["ok"], json!(false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_chosen_token_must_be_long_enough() {
|
||||
assert!(make_token(1, Some("short".into())).is_err());
|
||||
assert_eq!(make_token(1, Some(format!(" {TOKEN} "))).unwrap(), TOKEN);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
//! GridCraft desktop app.
|
||||
//!
|
||||
//! Usage: `gridcraft [--control <port>] [--sample <name>] [files…]`
|
||||
//! Usage: `gridcraft [--control <port> [--control-token <token>]] [--sample <name>] [files…]`
|
||||
//!
|
||||
//! `--control <port>` (or `GRIDCRAFT_CONTROL_PORT`) starts a localhost JSON-lines control
|
||||
//! server: `{"id":1,"method":"ui.inspect","params":{}}` → `{"id":1,"ok":true,"result":…}`.
|
||||
//! The first line of each connection must carry the token (`docs/control-protocol.md`).
|
||||
//! See `gridcraft_ui_egui::control` and `docs/control-protocol.md` for the methods.
|
||||
#![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")]
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
@@ -242,6 +243,7 @@ fn main() -> eframe::Result<()> {
|
||||
return Ok(());
|
||||
}
|
||||
let mut control_port: Option<u16> = std::env::var("GRIDCRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok());
|
||||
let mut control_token: Option<String> = std::env::var("GRIDCRAFT_CONTROL_TOKEN").ok();
|
||||
let mut sample: Option<String> = None;
|
||||
let mut files: Vec<String> = Vec::new();
|
||||
let mut i = 0;
|
||||
@@ -251,6 +253,10 @@ fn main() -> eframe::Result<()> {
|
||||
i += 1;
|
||||
control_port = args.get(i).and_then(|p| p.parse().ok());
|
||||
}
|
||||
"--control-token" => {
|
||||
i += 1;
|
||||
control_token = args.get(i).cloned();
|
||||
}
|
||||
"--sample" => {
|
||||
i += 1;
|
||||
sample = Some(args.get(i).cloned().unwrap_or_else(|| "sales".into()));
|
||||
@@ -338,7 +344,7 @@ fn main() -> eframe::Result<()> {
|
||||
};
|
||||
SheetApp::setup_context_for_language(&cc.egui_ctx, app.ui.dark, app.ui.language);
|
||||
if let Some(port) = control_port {
|
||||
match control_server::start(port, cc.egui_ctx.clone()) {
|
||||
match control_server::start(port, control_token.clone(), cc.egui_ctx.clone()) {
|
||||
Ok(rx) => app.control_rx = Some(rx),
|
||||
Err(e) => log::error!("control channel on port {port}: {e}"),
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user