aboutsummaryrefslogtreecommitdiffstats
path: root/src/api/cors.rs
diff options
context:
space:
mode:
Diffstat (limited to 'src/api/cors.rs')
-rw-r--r--src/api/cors.rs144
1 files changed, 144 insertions, 0 deletions
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<String> {
+ 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<AppState>,
+ request: Request<Body>,
+ 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"));
+ }
+}