From 1458ea92a41788a0f33e86e621817613f46055eb Mon Sep 17 00:00:00 2001 From: tv Date: Sat, 7 Nov 2026 22:35:09 +0100 Subject: dashboard: own links over the API, and CORS GET /admin/dashboard/links/own and its event stream: a node's own links, as LINKS_REPLY carried them, for other members' dashboards with a token. CORS for the members' dashboard origins and --cors-origins, without credentials. Auth plan M3. --- src/api/cors.rs | 144 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 144 insertions(+) create mode 100644 src/api/cors.rs (limited to 'src/api/cors.rs') diff --git a/src/api/cors.rs b/src/api/cors.rs new file mode 100644 index 0000000..313d331 --- /dev/null +++ b/src/api/cors.rs @@ -0,0 +1,144 @@ +//! Pages of other origins reading this node: the dashboards of other members, which a +//! page subscribes to for their own links (`/admin/dashboard/links/own`), 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 { + matches!( + path, + "/admin/dashboard/links/own" + | "/admin/dashboard/links/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("/auth/refresh")); + assert!(!open("/auth/login")); + assert!(!open("/store")); + assert!(!open("/admin/dashboard/state")); + } +} -- cgit v1.3.1