fix(access): recognize localhost through Compose

Co-Authored-By: GitHub Copilot <noreply@github.com>
This commit is contained in:
Steve Biedermann 2026-10-13 17:07:26 +02:00
parent 0332783a78
commit bf0cb7d0c7
3 changed files with 62 additions and 30 deletions

View File

@ -12,7 +12,7 @@ People, animals and carts that cross a shard border are migrated with their whol
## Public access ## 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 ## Economy

View File

@ -6,11 +6,12 @@ services:
SIM_ADDR: 0.0.0.0:7878 SIM_ADDR: 0.0.0.0:7878
SIM_CONFIG: /etc/sim/config.rn SIM_CONFIG: /etc/sim/config.rn
SIM_SHARDS: "3" SIM_SHARDS: "3"
SIM_TRUST_PROXY_HEADERS: "1"
SHARD_DATABASE_URL: postgres://sim:sim@db:5432/sim_shard_{shard} SHARD_DATABASE_URL: postgres://sim:sim@db:5432/sim_shard_{shard}
volumes: volumes:
- ./config.rn:/etc/sim/config.rn:ro - ./config.rn:/etc/sim/config.rn:ro
ports: ports:
- "7878:7878" - "127.0.0.1:7878:7878"
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy

View File

@ -35,10 +35,11 @@ struct App {
script: Arc<Mutex<String>>, script: Arc<Mutex<String>>,
state: watch::Receiver<Arc<str>>, state: watch::Receiver<Arc<str>>,
tx: mpsc::UnboundedSender<Msg>, tx: mpsc::UnboundedSender<Msg>,
trust_proxy_headers: bool,
} }
/// Rejects cross-site requests and remote write requests. /// Rejects cross-site requests and remote write requests.
async fn same_origin(req: Request, next: Next) -> Response { async fn same_origin(State(app): State<App>, req: Request, next: Next) -> Response {
let h = req.headers(); let h = req.headers();
if let Some(origin) = h.get(header::ORIGIN).and_then(|v| v.to_str().ok()) { if let Some(origin) = h.get(header::ORIGIN).and_then(|v| v.to_str().ok()) {
let host = h let host = h
@ -54,7 +55,7 @@ async fn same_origin(req: Request, next: Next) -> Response {
.extensions() .extensions()
.get::<ConnectInfo<SocketAddr>>() .get::<ConnectInfo<SocketAddr>>()
.map(|ConnectInfo(address)| *address); .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") && (req.method() != axum::http::Method::GET || req.uri().path() == "/api/actors/ws")
{ {
return StatusCode::FORBIDDEN.into_response(); return StatusCode::FORBIDDEN.into_response();
@ -62,28 +63,28 @@ async fn same_origin(req: Request, next: Next) -> Response {
next.run(req).await next.run(req).await
} }
fn can_edit(peer: Option<SocketAddr>, headers: &HeaderMap) -> bool { fn can_edit(peer: Option<SocketAddr>, headers: &HeaderMap, trust_proxy_headers: bool) -> bool {
let Some(peer) = peer else {
return false;
};
if !peer.ip().is_loopback() {
return false;
}
let client_ips = headers let client_ips = headers
.get("x-forwarded-for") .get("x-forwarded-for")
.or_else(|| headers.get("x-real-ip")); .or_else(|| headers.get("x-real-ip"));
let Some(client_ips) = client_ips else { if let Some(client_ips) = client_ips {
return true; return (trust_proxy_headers || peer.is_some_and(|address| address.ip().is_loopback()))
}; && client_ips
let Ok(client_ips) = client_ips.to_str() else { .to_str()
return false; .ok()
}; .and_then(|ips| ips.split(',').next())
let addresses = client_ips.split(',').map(str::trim).collect::<Vec<_>>(); .and_then(|ip| ip.trim().parse::<IpAddr>().ok())
!addresses.is_empty() .is_some_and(|ip| ip.is_loopback());
&& addresses }
.iter()
.all(|address| address.parse::<IpAddr>().is_ok_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::<axum::http::uri::Authority>().ok())
.is_some_and(|authority| {
matches!(authority.host(), "localhost" | "127.0.0.1" | "[::1]")
})
} }
fn json(body: String) -> impl IntoResponse { fn json(body: String) -> impl IntoResponse {
@ -114,8 +115,12 @@ async fn state(State(app): State<App>) -> impl IntoResponse {
json(app.state.borrow().to_string()) json(app.state.borrow().to_string())
} }
async fn access(ConnectInfo(peer): ConnectInfo<SocketAddr>, headers: HeaderMap) -> Json<bool> { async fn access(
Json(can_edit(Some(peer), &headers)) State(app): State<App>,
ConnectInfo(peer): ConnectInfo<SocketAddr>,
headers: HeaderMap,
) -> Json<bool> {
Json(can_edit(Some(peer), &headers, app.trust_proxy_headers))
} }
async fn get_script(State(app): State<App>) -> String { async fn get_script(State(app): State<App>) -> String {
@ -165,7 +170,7 @@ async fn ws(
ConnectInfo(peer): ConnectInfo<SocketAddr>, ConnectInfo(peer): ConnectInfo<SocketAddr>,
headers: HeaderMap, headers: HeaderMap,
) -> Response { ) -> 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)) upgrade.on_upgrade(move |socket| client(socket, app, can_edit))
} }
@ -385,6 +390,8 @@ async fn main() {
script, script,
state: state_rx, state: state_rx,
tx, tx,
trust_proxy_headers: std::env::var("SIM_TRUST_PROXY_HEADERS")
.is_ok_and(|value| value == "1"),
}; };
let router = Router::new() let router = Router::new()
.route("/", get(index)) .route("/", get(index))
@ -401,7 +408,7 @@ async fn main() {
let router = router.route("/api/actors/ws", get(picard_ws)); let router = router.route("/api/actors/ws", get(picard_ws));
let router = router let router = router
.layer(DefaultBodyLimit::max(65536)) .layer(DefaultBodyLimit::max(65536))
.layer(middleware::from_fn(same_origin)) .layer(middleware::from_fn_with_state(app.clone(), same_origin))
.with_state(app); .with_state(app);
println!("Simulation running at http://{addr} with {shards} shards"); println!("Simulation running at http://{addr} with {shards} shards");
@ -420,7 +427,7 @@ mod access_tests {
#[test] #[test]
fn local_clients_can_edit_without_a_proxy_header() { fn local_clients_can_edit_without_a_proxy_header() {
let peer = "127.0.0.1:7878".parse().unwrap(); 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] #[test]
@ -428,7 +435,23 @@ mod access_tests {
let peer = "127.0.0.1:7878".parse().unwrap(); let peer = "127.0.0.1:7878".parse().unwrap();
let mut headers = HeaderMap::new(); let mut headers = HeaderMap::new();
headers.insert("x-forwarded-for", "203.0.113.9".parse().unwrap()); 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] #[test]
@ -436,6 +459,14 @@ mod access_tests {
let peer = "203.0.113.8:7878".parse().unwrap(); let peer = "203.0.113.8:7878".parse().unwrap();
let mut headers = HeaderMap::new(); let mut headers = HeaderMap::new();
headers.insert("x-forwarded-for", "127.0.0.1".parse().unwrap()); 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));
} }
} }