Skip to main content

sl_map_web/
csrf.rs

1//! Cross-site request forgery defense.
2//!
3//! Reject state-changing requests whose `Origin` (or, failing that,
4//! `Referer`) does not match the configured public base URL. The session
5//! cookie uses `SameSite=Lax`, which on its own does not block same-site
6//! sub-resource requests nor multipart form submissions from a third-party
7//! origin, so cookie-authenticated routes need this second layer of defence.
8
9use axum::extract::{Request, State};
10use axum::http::{Method, header};
11use axum::middleware::Next;
12use axum::response::{IntoResponse as _, Response};
13
14use crate::error::Error;
15use crate::state::AppState;
16
17/// Middleware that enforces same-origin policy on state-changing requests.
18///
19/// Safe methods (`GET`, `HEAD`, `OPTIONS`) are forwarded unchanged. Unsafe
20/// methods must carry an `Origin` header matching `config.public_base_url`
21/// (or, as a fallback for clients that omit `Origin`, a `Referer` whose
22/// origin matches). Requests with neither header, or with a mismatched
23/// value, are rejected with [`Error::Forbidden`].
24pub async fn require_same_origin(
25    State(state): State<AppState>,
26    req: Request,
27    next: Next,
28) -> Response {
29    if is_safe_method(req.method()) {
30        return next.run(req).await;
31    }
32    let expected = state.config.public_base_url.trim_end_matches('/');
33    let headers = req.headers();
34    if let Some(origin) = header_str(headers, &header::ORIGIN) {
35        if origin.trim_end_matches('/') == expected {
36            return next.run(req).await;
37        }
38        return Error::Forbidden(String::from("origin mismatch")).into_response();
39    }
40    if let Some(referer) = header_str(headers, &header::REFERER) {
41        if origin_of(referer).is_some_and(|o| o == expected) {
42            return next.run(req).await;
43        }
44        return Error::Forbidden(String::from("referer origin mismatch")).into_response();
45    }
46    Error::Forbidden(String::from("missing Origin and Referer headers")).into_response()
47}
48
49/// Methods exempt from the same-origin check (RFC 9110 "safe" methods).
50const fn is_safe_method(method: &Method) -> bool {
51    matches!(*method, Method::GET | Method::HEAD | Method::OPTIONS)
52}
53
54/// Read a header value as UTF-8, returning `None` if it is missing or not
55/// valid UTF-8.
56fn header_str<'a>(
57    headers: &'a axum::http::HeaderMap,
58    name: &header::HeaderName,
59) -> Option<&'a str> {
60    headers.get(name).and_then(|v| v.to_str().ok())
61}
62
63/// Extract the origin (`scheme://host[:port]`) from a full URL by stopping
64/// at the first `/` after `scheme://`. Returns `None` if the input does not
65/// look like an absolute URL.
66fn origin_of(url: &str) -> Option<&str> {
67    let scheme_end = url.find("://")?;
68    let after_scheme = scheme_end.checked_add(3)?;
69    let rest = url.get(after_scheme..)?;
70    let host_end = rest.find('/').unwrap_or(rest.len());
71    let total_end = after_scheme.checked_add(host_end)?;
72    url.get(..total_end)
73}
74
75#[cfg(test)]
76mod tests {
77    use pretty_assertions::assert_eq;
78
79    use super::origin_of;
80
81    #[test]
82    fn origin_of_strips_path_and_query() {
83        assert_eq!(
84            origin_of("https://maps.example.org/library?x=1"),
85            Some("https://maps.example.org"),
86        );
87    }
88
89    #[test]
90    fn origin_of_keeps_port() {
91        assert_eq!(
92            origin_of("http://localhost:3000/foo"),
93            Some("http://localhost:3000"),
94        );
95    }
96
97    #[test]
98    fn origin_of_with_no_path() {
99        assert_eq!(
100            origin_of("https://maps.example.org"),
101            Some("https://maps.example.org"),
102        );
103    }
104
105    #[test]
106    fn origin_of_rejects_non_absolute() {
107        assert_eq!(origin_of("/library"), None);
108    }
109}