fix(access): recognize localhost through Compose
Co-Authored-By: GitHub Copilot <noreply@github.com>
This commit is contained in:
parent
0332783a78
commit
bf0cb7d0c7
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
87
src/main.rs
87
src/main.rs
|
|
@ -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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue