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
|
||||
|
||||
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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
87
src/main.rs
87
src/main.rs
|
|
@ -35,10 +35,11 @@ struct App {
|
|||
script: Arc<Mutex<String>>,
|
||||
state: watch::Receiver<Arc<str>>,
|
||||
tx: mpsc::UnboundedSender<Msg>,
|
||||
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<App>, 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::<ConnectInfo<SocketAddr>>()
|
||||
.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<SocketAddr>, headers: &HeaderMap) -> bool {
|
||||
let Some(peer) = peer else {
|
||||
return false;
|
||||
};
|
||||
if !peer.ip().is_loopback() {
|
||||
return false;
|
||||
}
|
||||
|
||||
fn can_edit(peer: Option<SocketAddr>, 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::<Vec<_>>();
|
||||
!addresses.is_empty()
|
||||
&& addresses
|
||||
.iter()
|
||||
.all(|address| address.parse::<IpAddr>().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::<IpAddr>().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::<axum::http::uri::Authority>().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<App>) -> impl IntoResponse {
|
|||
json(app.state.borrow().to_string())
|
||||
}
|
||||
|
||||
async fn access(ConnectInfo(peer): ConnectInfo<SocketAddr>, headers: HeaderMap) -> Json<bool> {
|
||||
Json(can_edit(Some(peer), &headers))
|
||||
async fn access(
|
||||
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 {
|
||||
|
|
@ -165,7 +170,7 @@ async fn ws(
|
|||
ConnectInfo(peer): ConnectInfo<SocketAddr>,
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue