From 0332783a7899939959edec7b7a45d9ae5d94e846 Mon Sep 17 00:00:00 2001 From: Steve Biedermann Date: Tue, 13 Oct 2026 16:46:00 +0200 Subject: [PATCH] fix(access): restrict public clients to read-only Co-Authored-By: GitHub Copilot --- CHANGELOG.md | 1 + README.md | 4 +++ src/main.rs | 95 ++++++++++++++++++++++++++++++++++++++++++++++----- ui/src/lib.rs | 10 ++++++ 4 files changed, 101 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 76a7111..bb0f4bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ Notable changes to this project are documented here. ### Added +- Public clients are view-only: the UI hides editing controls and the server rejects remote writes; localhost clients retain editing access. - Expose the Picard actor console over the `/api/actors/ws` WebSocket endpoint. - The world can be split into shards that each own a strip of columns. People and animals that walk across a shard border are transferred with their inventory and stay reachable at the same address. - Jobs and trading: every shard founds markets, farms, woodcutters, quarries, hunters, toolmakers and cartwrights. Adults are hired, commute to their job site each day, work their shift, sell the goods at the market, get paid and go home. diff --git a/README.md b/README.md index b1b399c..6b19d7a 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,10 @@ The world is split into vertical strips of columns called shards. Each shard is People, animals and carts that cross a shard border are migrated with their whole state and inventory into the neighbouring shard's Picard actor system. A cluster-wide directory maps each entity address to its current shard and actor path, so world messages continue to reach it after migration without actor-owned forwarding timers or transfer messages. Shards connect directly through Picard's authenticated remoting endpoint (`/remoting`); the gateway continues to use `/control` for simulation coordination. The gateway generates a shared cluster token and passes it to its shard processes for peer authentication. +## Public access + +Clients outside localhost can view the simulation but cannot edit it: the UI hides editing controls, and the server rejects remote write requests and WebSocket edit commands. When exposing the app through a reverse proxy, keep the app listener on loopback and ensure the proxy sets `X-Forwarded-For` or `X-Real-IP`; forwarded client addresses are trusted only when the proxy connects from loopback. + ## Economy Every shard founds markets and workplaces: farms, woodcutters, quarries, hunters, toolmakers and cartwrights. Adults are hired, commute to their job site each day, work their shift, sell the goods at a market, get paid and go home. Farmers sow fields that ripen into crops; gatherers fell trees, quarry stone and hunt; crafters buy materials and make tools and carts. Markets are actors with their own coins and stock; prices follow supply and demand, and buyers and sellers haggle. Merchants carry surplus goods to markets on other shards. Markets, workplaces, carts and crop fields are drawn on the map, and the side panel shows each person's job and coins. diff --git a/src/main.rs b/src/main.rs index a6145da..51778fd 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,14 +4,15 @@ mod world; use axum::Router; use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade}; -use axum::extract::{DefaultBodyLimit, Path, Request, State}; -use axum::http::{StatusCode, header}; +use axum::extract::{ConnectInfo, DefaultBodyLimit, Path, Request, State}; +use axum::http::{HeaderMap, StatusCode, header}; use axum::middleware::{self, Next}; -use axum::response::{Html, IntoResponse, Response}; +use axum::response::{Html, IntoResponse, Json, Response}; use axum::routing::{get, post}; use cluster::gateway::{GatewayOptions, Msg, Outputs}; use cluster::shard::ShardOptions; use include_dir::{Dir, include_dir}; +use std::net::{IpAddr, SocketAddr}; use std::sync::{Arc, Mutex}; use std::time::Duration; use tokio::sync::{mpsc, oneshot, watch}; @@ -36,7 +37,7 @@ struct App { tx: mpsc::UnboundedSender, } -/// Rejects cross-site requests (including WebSocket upgrades), which could otherwise edit the world. +/// Rejects cross-site requests and remote write requests. async fn same_origin(req: Request, next: Next) -> Response { let h = req.headers(); if let Some(origin) = h.get(header::ORIGIN).and_then(|v| v.to_str().ok()) { @@ -49,9 +50,42 @@ async fn same_origin(req: Request, next: Next) -> Response { return StatusCode::FORBIDDEN.into_response(); } } + let peer = req + .extensions() + .get::>() + .map(|ConnectInfo(address)| *address); + if !can_edit(peer, h) + && (req.method() != axum::http::Method::GET || req.uri().path() == "/api/actors/ws") + { + return StatusCode::FORBIDDEN.into_response(); + } next.run(req).await } +fn can_edit(peer: Option, headers: &HeaderMap) -> bool { + let Some(peer) = peer else { + return false; + }; + if !peer.ip().is_loopback() { + return false; + } + + let client_ips = headers + .get("x-forwarded-for") + .or_else(|| headers.get("x-real-ip")); + let Some(client_ips) = client_ips else { + return true; + }; + let Ok(client_ips) = client_ips.to_str() else { + return false; + }; + let addresses = client_ips.split(',').map(str::trim).collect::>(); + !addresses.is_empty() + && addresses + .iter() + .all(|address| address.parse::().is_ok_and(|ip| ip.is_loopback())) +} + fn json(body: String) -> impl IntoResponse { ([(header::CONTENT_TYPE, "application/json")], body) } @@ -80,6 +114,10 @@ async fn state(State(app): State) -> impl IntoResponse { json(app.state.borrow().to_string()) } +async fn access(ConnectInfo(peer): ConnectInfo, headers: HeaderMap) -> Json { + Json(can_edit(Some(peer), &headers)) +} + async fn get_script(State(app): State) -> String { app.script.lock().unwrap().clone() } @@ -121,8 +159,14 @@ async fn post_edit(State(app): State, body: String) -> StatusCode { StatusCode::OK } -async fn ws(upgrade: WebSocketUpgrade, State(app): State) -> Response { - upgrade.on_upgrade(move |socket| client(socket, app)) +async fn ws( + upgrade: WebSocketUpgrade, + State(app): State, + ConnectInfo(peer): ConnectInfo, + headers: HeaderMap, +) -> Response { + let can_edit = can_edit(Some(peer), &headers); + upgrade.on_upgrade(move |socket| client(socket, app, can_edit)) } #[cfg(feature = "console")] @@ -188,7 +232,7 @@ async fn picard_client(mut socket: WebSocket, app: App, shard: usize) { } /// Pushes every new snapshot to the client; text frames from the client are edit commands. -async fn client(mut socket: WebSocket, app: App) { +async fn client(mut socket: WebSocket, app: App, can_edit: bool) { let mut rx = app.state.clone(); loop { let snapshot = rx.borrow_and_update().to_string(); @@ -204,7 +248,7 @@ async fn client(mut socket: WebSocket, app: App) { break; } msg = socket.recv() => match msg { - Some(Ok(Message::Text(t))) if t.len() <= 256 => { + Some(Ok(Message::Text(t))) if can_edit && t.len() <= 256 => { let _ = app.tx.send(Msg::Edit(t.as_str().to_owned())); } Some(Ok(Message::Close(_)) | Err(_)) | None => return, @@ -344,6 +388,7 @@ async fn main() { }; let router = Router::new() .route("/", get(index)) + .route("/api/access", get(access)) .route("/api/terrain", get(terrain)) .route("/api/state", get(state)) .route("/api/script", get(get_script).post(post_script)) @@ -360,5 +405,37 @@ async fn main() { .with_state(app); println!("Simulation running at http://{addr} with {shards} shards"); - axum::serve(listener, router).await.unwrap(); + axum::serve( + listener, + router.into_make_service_with_connect_info::(), + ) + .await + .unwrap(); +} + +#[cfg(test)] +mod access_tests { + use super::*; + + #[test] + fn local_clients_can_edit_without_a_proxy_header() { + let peer = "127.0.0.1:7878".parse().unwrap(); + assert!(can_edit(Some(peer), &HeaderMap::new())); + } + + #[test] + fn forwarded_remote_clients_are_read_only() { + let peer = "127.0.0.1:7878".parse().unwrap(); + let mut headers = HeaderMap::new(); + headers.insert("x-forwarded-for", "203.0.113.9".parse().unwrap()); + assert!(!can_edit(Some(peer), &headers)); + } + + #[test] + fn remote_peers_cannot_spoof_a_loopback_forwarded_address() { + let peer = "203.0.113.8:7878".parse().unwrap(); + let mut headers = HeaderMap::new(); + headers.insert("x-forwarded-for", "127.0.0.1".parse().unwrap()); + assert!(!can_edit(Some(peer), &headers)); + } } diff --git a/ui/src/lib.rs b/ui/src/lib.rs index de564ea..5bdc8df 100644 --- a/ui/src/lib.rs +++ b/ui/src/lib.rs @@ -39,9 +39,14 @@ fn App() -> impl IntoView { let tool = RwSignal::new("inspect".to_string()); let param = RwSignal::new(String::new()); let amount = RwSignal::new("5".to_string()); + let can_edit = RwSignal::new(false); // (selection counter, people at the time of selection); the counter forces editor rows to rebuild. let editing = RwSignal::new((0u64, Vec::::new())); + spawn_local(async move { + can_edit.set(api::get_json::("/api/access").await.unwrap_or(false)); + }); + // The server pushes a snapshot per tick; terrain is refetched only when its revision changes. let last_rev = StoredValue::new(None::); api::connect(move |text| { @@ -333,6 +338,10 @@ fn App() -> impl IntoView { )} } }))} + "View-only access"

} + >