diff --git a/README.md b/README.md index 6b19d7a..3037006 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ People, animals and carts that cross a shard border are migrated with their whol ## 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. +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 published port bound to host loopback and ensure the proxy sets `X-Forwarded-For` or `X-Real-IP`. Compose enables `SIM_TRUST_PROXY_HEADERS=1` for this setup. Docker-host requests to `localhost` remain editable, while forwarded requests are classified by their client IP. ## Economy diff --git a/compose.yaml b/compose.yaml index f3f815e..d8157ed 100644 --- a/compose.yaml +++ b/compose.yaml @@ -6,11 +6,12 @@ services: SIM_ADDR: 0.0.0.0:7878 SIM_CONFIG: /etc/sim/config.rn SIM_SHARDS: "3" + SIM_TRUST_PROXY_HEADERS: "1" SHARD_DATABASE_URL: postgres://sim:sim@db:5432/sim_shard_{shard} volumes: - ./config.rn:/etc/sim/config.rn:ro ports: - - "7878:7878" + - "127.0.0.1:7878:7878" depends_on: db: condition: service_healthy diff --git a/src/main.rs b/src/main.rs index 51778fd..276a916 100644 --- a/src/main.rs +++ b/src/main.rs @@ -35,10 +35,11 @@ struct App { script: Arc>, state: watch::Receiver>, tx: mpsc::UnboundedSender, + trust_proxy_headers: bool, } /// Rejects cross-site requests and remote write requests. -async fn same_origin(req: Request, next: Next) -> Response { +async fn same_origin(State(app): State, req: Request, next: Next) -> Response { let h = req.headers(); if let Some(origin) = h.get(header::ORIGIN).and_then(|v| v.to_str().ok()) { let host = h @@ -54,7 +55,7 @@ async fn same_origin(req: Request, next: Next) -> Response { .extensions() .get::>() .map(|ConnectInfo(address)| *address); - if !can_edit(peer, h) + if !can_edit(peer, h, app.trust_proxy_headers) && (req.method() != axum::http::Method::GET || req.uri().path() == "/api/actors/ws") { return StatusCode::FORBIDDEN.into_response(); @@ -62,28 +63,28 @@ async fn same_origin(req: Request, next: Next) -> 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; - } - +fn can_edit(peer: Option, headers: &HeaderMap, trust_proxy_headers: bool) -> bool { 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())) + if let Some(client_ips) = client_ips { + return (trust_proxy_headers || peer.is_some_and(|address| address.ip().is_loopback())) + && client_ips + .to_str() + .ok() + .and_then(|ips| ips.split(',').next()) + .and_then(|ip| ip.trim().parse::().ok()) + .is_some_and(|ip| ip.is_loopback()); + } + + peer.is_some_and(|address| address.ip().is_loopback()) + || headers + .get(header::HOST) + .and_then(|host| host.to_str().ok()) + .and_then(|host| host.parse::().ok()) + .is_some_and(|authority| { + matches!(authority.host(), "localhost" | "127.0.0.1" | "[::1]") + }) } fn json(body: String) -> impl IntoResponse { @@ -114,8 +115,12 @@ 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 access( + State(app): State, + ConnectInfo(peer): ConnectInfo, + headers: HeaderMap, +) -> Json { + Json(can_edit(Some(peer), &headers, app.trust_proxy_headers)) } async fn get_script(State(app): State) -> String { @@ -165,7 +170,7 @@ async fn ws( ConnectInfo(peer): ConnectInfo, headers: HeaderMap, ) -> Response { - let can_edit = can_edit(Some(peer), &headers); + let can_edit = can_edit(Some(peer), &headers, app.trust_proxy_headers); upgrade.on_upgrade(move |socket| client(socket, app, can_edit)) } @@ -385,6 +390,8 @@ async fn main() { script, state: state_rx, tx, + trust_proxy_headers: std::env::var("SIM_TRUST_PROXY_HEADERS") + .is_ok_and(|value| value == "1"), }; let router = Router::new() .route("/", get(index)) @@ -401,7 +408,7 @@ async fn main() { let router = router.route("/api/actors/ws", get(picard_ws)); let router = router .layer(DefaultBodyLimit::max(65536)) - .layer(middleware::from_fn(same_origin)) + .layer(middleware::from_fn_with_state(app.clone(), same_origin)) .with_state(app); println!("Simulation running at http://{addr} with {shards} shards"); @@ -420,7 +427,7 @@ mod access_tests { #[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())); + assert!(can_edit(Some(peer), &HeaderMap::new(), false)); } #[test] @@ -428,7 +435,23 @@ mod access_tests { 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)); + assert!(!can_edit(Some(peer), &headers, false)); + } + + #[test] + fn localhost_host_is_local_through_docker_port_mapping() { + let peer = "172.18.0.1:7878".parse().unwrap(); + let mut headers = HeaderMap::new(); + headers.insert(header::HOST, "localhost:7878".parse().unwrap()); + assert!(can_edit(Some(peer), &headers, false)); + } + + #[test] + fn public_host_without_a_forwarded_local_address_is_read_only() { + let peer = "172.18.0.1:7878".parse().unwrap(); + let mut headers = HeaderMap::new(); + headers.insert(header::HOST, "sim.example.com".parse().unwrap()); + assert!(!can_edit(Some(peer), &headers, false)); } #[test] @@ -436,6 +459,14 @@ mod access_tests { 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)); + assert!(!can_edit(Some(peer), &headers, false)); + } + + #[test] + fn trusted_proxy_loopback_client_is_local() { + let peer = "172.18.0.1: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, true)); } }