Skip to main content

hennery_kernel/
origin.rs

1//! The browser rules (kernel spec ยง3.3): what every browser route checks
2//! before its handler, cookie or not.
3//!
4//! - **State-changing methods** (every method but `GET` and `HEAD`): `Origin`
5//!   must be `public_url`'s, and a missing `Origin` is refused. Before setup
6//!   there is no `public_url`, so they are refused outright. The body must be
7//!   JSON: a `Content-Type` other than `application/json` is refused, and so
8//!   is a body without one. A body-less request (park, logout, revoke) needs
9//!   no `Content-Type`.
10//! - **`GET` and `HEAD`**: browsers send no `Origin` on a same-origin `GET`,
11//!   so `Sec-Fetch-Site` is checked instead: `same-origin` or `none`, or
12//!   absent (a client that is not a browser, or one too old to send it;
13//!   the `SameSite=Strict` cookie still keeps cross-site requests out). A
14//!   present `Origin` must match.
15//!
16//! No `GET` or `HEAD` route changes state; one that must is a `POST`.
17//! Accepting a missing `Sec-Fetch-Site` relies on this.
18//!
19//! Host enrollment, the host WebSocket and setup are not browser routes in
20//! this sense and sit outside this layer (setup has its own rule).
21
22use crate::auth_api::error;
23use crate::operator::Operator;
24use axum::extract::{Request, State};
25use axum::http::{HeaderMap, Method, StatusCode, header};
26use axum::middleware::Next;
27use axum::response::Response;
28use std::sync::Arc;
29
30pub async fn browser_rules(State(operator): State<Arc<Operator>>, req: Request, next: Next) -> Response {
31    let headers = req.headers();
32    // A header that is not visible ASCII matches nothing.
33    let origin = headers.get(header::ORIGIN).map(|v| v.to_str().unwrap_or("\u{0}"));
34    let public_url = operator.public_url();
35    let expected = public_url.as_ref().map(|u| u.origin());
36    if is_state_changing(req.method()) {
37        if expected.is_none() {
38            return error(StatusCode::FORBIDDEN, "setup_required", "hennery is not set up yet");
39        }
40        if origin != expected {
41            tracing::debug!(?expected, received = ?origin, "request refused: origin_mismatch");
42            return error(
43                StatusCode::FORBIDDEN,
44                "origin_mismatch",
45                "state-changing requests must come from the public_url's origin",
46            );
47        }
48        if !is_json_or_empty(headers) {
49            return error(
50                StatusCode::UNSUPPORTED_MEDIA_TYPE,
51                "unsupported_media_type",
52                "the body must be application/json",
53            );
54        }
55    } else {
56        let site = headers.get("sec-fetch-site").map(|v| v.to_str().unwrap_or(""));
57        if site.is_some_and(|s| s != "same-origin" && s != "none") {
58            return error(StatusCode::FORBIDDEN, "cross_site", "cross-site requests are refused");
59        }
60        if origin.is_some() && origin != expected {
61            tracing::debug!(?expected, received = ?origin, "request refused: origin_mismatch");
62            return error(
63                StatusCode::FORBIDDEN,
64                "origin_mismatch",
65                "a request's Origin must be the public_url's",
66            );
67        }
68    }
69    next.run(req).await
70}
71
72fn is_state_changing(method: &Method) -> bool {
73    method != Method::GET && method != Method::HEAD
74}
75
76/// `application/json` (any parameters), or no body at all.
77fn is_json_or_empty(headers: &HeaderMap) -> bool {
78    match headers.get(header::CONTENT_TYPE) {
79        Some(value) => value
80            .to_str()
81            .ok()
82            .and_then(|v| v.split(';').next())
83            .is_some_and(|essence| essence.trim().eq_ignore_ascii_case("application/json")),
84        None => {
85            !headers.contains_key(header::TRANSFER_ENCODING)
86                && headers
87                    .get(header::CONTENT_LENGTH)
88                    .is_none_or(|len| len.to_str().is_ok_and(|len| len.trim() == "0"))
89        }
90    }
91}