//! Pages of other origins reading this node: the dashboards of other members, which a //! page subscribes to for their own links and messages (`/admin/dashboard/links/own`, //! `/admin/dashboard/messages/own/events`, and one written out) and figures //! (`/admin/stats`, `/peers`), refreshing its //! token on any node (`/auth/refresh`, `/auth/whoami`). Only those endpoints, only for //! the origins of the dashboards the member list names and those of `--cors-origins`, //! and without credentials: a page sends its token itself (`Authorization: Bearer`), //! so no cookie or password crosses origins (`access.md`, Tokens). use axum::{ body::Body, extract::State, http::{HeaderValue, Method, Request, StatusCode, header}, response::{IntoResponse, Response}, }; use crate::state::AppState; use crate::swarm::members; /// The endpoints other origins may call. fn open(path: &str) -> bool { // A message written out (`/admin/dashboard/messages/own/{seq}`). let message = path .strip_prefix("/admin/dashboard/messages/own/") .is_some_and(|seq| !seq.is_empty() && seq.bytes().all(|byte| byte.is_ascii_digit())); message || matches!( path, "/admin/dashboard/links/own" | "/admin/stats" | "/peers" | "/admin/dashboard/links/own/events" | "/admin/dashboard/messages/own/events" | "/auth/refresh" | "/auth/whoami" ) } /// `url`'s origin (`scheme://host[:port]`), if it is a URL with one. fn origin_of(url: &str) -> Option { let parsed = url::Url::parse(url).ok()?; let origin = parsed.origin(); origin.is_tuple().then(|| origin.ascii_serialization()) } /// Whether pages of `origin` may call `open` endpoints: a member's dashboard's, or one /// of `--cors-origins`. async fn allowed(state: &AppState, origin: &str) -> bool { if state.cors_origins.iter().any(|allowed| allowed == origin) { return true; } let replica = state.doc.read().await; members::members(&replica.doc).iter().any(|member| { members::dashboard(&replica.doc, member) .and_then(|url| origin_of(&url)) .is_some_and(|theirs| theirs == origin) }) } /// Answers a preflight (`OPTIONS` with `Access-Control-Request-Method`) to an open /// endpoint from an allowed origin, and adds the CORS headers to the answers of the /// others from there; leaves everything else as it is (a browser then keeps a page of /// another origin from reading it). Outside authentication: a preflight brings no /// credentials. pub(crate) async fn cors( State(state): State, request: Request, next: axum::middleware::Next, ) -> Response { let origin = request .headers() .get(header::ORIGIN) .and_then(|value| value.to_str().ok()) .map(str::to_owned); let Some(origin) = origin.filter(|_| open(request.uri().path())) else { return next.run(request).await; }; if !allowed(&state, &origin).await { return next.run(request).await; } let Ok(origin) = HeaderValue::from_str(&origin) else { return next.run(request).await; }; let preflight = request.method() == Method::OPTIONS && request .headers() .contains_key(header::ACCESS_CONTROL_REQUEST_METHOD); // Chrome's Private Network Access: a page on a public origin reaching a private // address asks first. let private = request .headers() .get("access-control-request-private-network") .is_some_and(|value| value == "true"); let mut response = if preflight { let mut response = StatusCode::NO_CONTENT.into_response(); let headers = response.headers_mut(); headers.insert( header::ACCESS_CONTROL_ALLOW_METHODS, HeaderValue::from_static("GET, POST"), ); headers.insert( header::ACCESS_CONTROL_ALLOW_HEADERS, HeaderValue::from_static("authorization, content-type"), ); headers.insert( header::ACCESS_CONTROL_MAX_AGE, HeaderValue::from_static("600"), ); if private { headers.insert( "access-control-allow-private-network", HeaderValue::from_static("true"), ); } response } else { next.run(request).await }; let headers = response.headers_mut(); headers.insert(header::ACCESS_CONTROL_ALLOW_ORIGIN, origin); headers.append(header::VARY, HeaderValue::from_static("Origin")); response } #[cfg(test)] mod tests { use super::*; #[test] fn origins() { assert_eq!( origin_of("https://a.example.org/admin/dashboard").as_deref(), Some("https://a.example.org") ); assert_eq!( origin_of("http://10.0.0.1:3000/admin/dashboard").as_deref(), Some("http://10.0.0.1:3000") ); assert_eq!( origin_of("https://a.example.org:443/").as_deref(), Some("https://a.example.org") ); assert_eq!(origin_of("not a url"), None); } #[test] fn only_some_endpoints() { assert!(open("/admin/dashboard/links/own")); assert!(open("/admin/dashboard/messages/own/42")); assert!(!open("/admin/dashboard/messages/own/x")); assert!(open("/admin/stats") && open("/peers")); assert!(!open("/peers/continuum:00")); assert!(open("/auth/refresh")); assert!(!open("/auth/login")); assert!(!open("/store")); assert!(!open("/admin/dashboard/state")); } }