Skip to main content

siderite_core/middleware/
csrf.rs

1//! Origin-checking CSRF protection for cookie-authenticated routes.
2
3use super::adapt::{BoxService, Next, from_fn, impl_layer};
4use crate::error::ApiError;
5use crate::extract::Request;
6use crate::response::{IntoResponse, Response};
7use http::header::{AUTHORIZATION, COOKIE, ORIGIN, REFERER};
8use http::uri::Authority;
9use http::{HeaderMap, HeaderName, Method, StatusCode};
10use std::sync::Arc;
11use thiserror::Error;
12use tower::Layer;
13
14const SEC_FETCH_SITE: HeaderName = HeaderName::from_static("sec-fetch-site");
15const FORBIDDEN: &str = "Request forbidden.";
16
17/// A configured origin that is not `scheme://host[:port]`.
18#[derive(Debug, Clone, PartialEq, Eq, Error)]
19#[error("invalid CSRF origin `{0}`: expected http(s)://host[:port]")]
20pub struct CsrfOriginError(String);
21
22/// Rejects cross-site state-changing requests by checking their origin.
23///
24/// Browsers attach cookies to requests a hostile page triggers, so a route
25/// that authenticates with cookies (or HTTP Basic) needs this check. For
26/// every method except `GET`, `HEAD`, `OPTIONS` and `TRACE`:
27///
28/// 1. `Sec-Fetch-Site: cross-site` is rejected, even when `Origin` would
29///    match; `Sec-Fetch-Site: same-origin` is accepted.
30/// 2. Otherwise `Origin` must equal one of the configured origins. When
31///    `Origin` is absent the origin of `Referer` is used instead.
32/// 3. `Origin: null`, a missing `Origin` and `Referer`, repeated headers
33///    and any other origin are rejected.
34///
35/// A rejection is one generic `403` problem that does not say which check
36/// failed. The allowed origins are configuration only: they are never
37/// derived from `Host` or `X-Forwarded-*`, which a client can choose.
38///
39/// ```
40/// use siderite_core::middleware::CsrfOrigin;
41///
42/// let csrf = CsrfOrigin::new(["https://app.example.com"])?
43///     .exempt("/webhooks/");
44/// # Ok::<(), siderite_core::middleware::CsrfOriginError>(())
45/// ```
46#[derive(Debug, Clone)]
47pub struct CsrfOrigin {
48    origins: Arc<[String]>,
49    exempt: Arc<[String]>,
50    only_with_cookie: bool,
51}
52
53impl CsrfOrigin {
54    /// Allow the given exact origins.
55    ///
56    /// Each origin is `scheme://host[:port]` with an `http` or `https`
57    /// scheme and no path, query, fragment or user info. It is lowercased
58    /// and a default port (80 for `http`, 443 for `https`) is removed, so
59    /// `HTTPS://App.Example.com:443` equals `https://app.example.com`.
60    ///
61    /// # Errors
62    /// [`CsrfOriginError`] for the first origin that does not parse.
63    pub fn new<I, S>(origins: I) -> Result<Self, CsrfOriginError>
64    where
65        I: IntoIterator<Item = S>,
66        S: AsRef<str>,
67    {
68        let mut parsed = Vec::new();
69        for raw in origins {
70            let raw = raw.as_ref();
71            let Some(origin) = parse_origin(raw) else {
72                return Err(CsrfOriginError(raw.to_owned()));
73            };
74            parsed.push(origin);
75        }
76        Ok(Self {
77            origins: parsed.into(),
78            exempt: Arc::default(),
79            only_with_cookie: false,
80        })
81    }
82
83    /// Skip the check for paths under `prefix`, for example a webhook.
84    ///
85    /// The prefix matches whole path segments: `/hooks` covers `/hooks`
86    /// and `/hooks/x` but not `/hooksy`. A prefix not starting with `/`
87    /// never matches, and paths with `.` or `..` segments are never
88    /// exempt. Exempt routes must authenticate callers some other way, for
89    /// example by verifying a signature.
90    #[must_use]
91    pub fn exempt(mut self, prefix: impl Into<String>) -> Self {
92        let mut list = self.exempt.to_vec();
93        list.push(prefix.into());
94        self.exempt = list.into();
95        self
96    }
97
98    /// Check only requests that carry ambient credentials (default off).
99    ///
100    /// When on, a request with no `Cookie` header and no
101    /// `Authorization: Basic` is not checked. That suits APIs that accept
102    /// both bearer tokens and cookies. The trade-off: a request that
103    /// arrives without credentials is passed to the handler, so every
104    /// route behind this setting must reject anonymous state changes
105    /// itself, and any other credential a browser sends by itself would
106    /// bypass the check.
107    #[must_use]
108    pub fn only_with_cookie(mut self) -> Self {
109        self.only_with_cookie = true;
110        self
111    }
112
113    fn is_exempt(&self, path: &str) -> bool {
114        if path.split('/').any(|part| part == "." || part == "..") {
115            return false;
116        }
117        self.exempt.iter().any(|prefix| {
118            prefix.starts_with('/')
119                && path.strip_prefix(prefix.as_str()).is_some_and(|rest| {
120                    let boundary = prefix.ends_with('/');
121                    rest.is_empty() || boundary || rest.starts_with('/')
122                })
123        })
124    }
125
126    fn permits(&self, req: &Request) -> bool {
127        let headers = req.headers();
128        if is_safe(req.method()) || self.is_exempt(req.uri().path()) {
129            return true;
130        }
131        if self.only_with_cookie && !has_ambient_credentials(headers) {
132            return true;
133        }
134        match single(headers, &SEC_FETCH_SITE) {
135            One::Invalid => return false,
136            One::Value(site) if site.eq_ignore_ascii_case("cross-site") => {
137                return false;
138            }
139            One::Value(site) if site.eq_ignore_ascii_case("same-origin") => {
140                return true;
141            }
142            _ => {}
143        }
144        let claimed = match single(headers, &ORIGIN) {
145            One::Invalid => return false,
146            One::Value(origin) => parse_origin(origin),
147            One::None => match single(headers, &REFERER) {
148                One::Value(referer) => referer_origin(referer),
149                _ => None,
150            },
151        };
152        claimed.is_some_and(|origin| self.origins.contains(&origin))
153    }
154
155    fn wrap(&self, inner: BoxService) -> BoxService {
156        let this = self.clone();
157        from_fn(move |req: Request, next: Next| {
158            let allowed = this.permits(&req);
159            async move {
160                if allowed {
161                    next.run(req).await
162                } else {
163                    forbidden()
164                }
165            }
166        })
167        .layer(inner)
168    }
169}
170
171fn forbidden() -> Response {
172    ApiError::new(StatusCode::FORBIDDEN, FORBIDDEN).into_response()
173}
174
175fn is_safe(method: &Method) -> bool {
176    matches!(
177        *method,
178        Method::GET | Method::HEAD | Method::OPTIONS | Method::TRACE
179    )
180}
181
182/// A `Cookie` header, or `Authorization: Basic`, rides along by itself.
183fn has_ambient_credentials(headers: &HeaderMap) -> bool {
184    if headers.contains_key(COOKIE) {
185        return true;
186    }
187    headers.get_all(AUTHORIZATION).iter().any(|value| {
188        value.to_str().ok().is_some_and(|text| {
189            let scheme = text.split(' ').next().unwrap_or_default();
190            scheme.eq_ignore_ascii_case("basic")
191        })
192    })
193}
194
195enum One<'a> {
196    None,
197    Value(&'a str),
198    Invalid,
199}
200
201/// The header's only value; repeated or non-ASCII values are `Invalid`.
202fn single<'a>(headers: &'a HeaderMap, name: &HeaderName) -> One<'a> {
203    let mut values = headers.get_all(name).iter();
204    match (values.next(), values.next()) {
205        (None, _) => One::None,
206        (Some(value), None) => value.to_str().map_or(One::Invalid, One::Value),
207        (Some(_), Some(_)) => One::Invalid,
208    }
209}
210
211/// Normalize `scheme://host[:port]`; `None` for anything else.
212fn parse_origin(raw: &str) -> Option<String> {
213    let (scheme, rest) = raw.split_once("://")?;
214    let scheme = scheme.to_ascii_lowercase();
215    let default_port = match scheme.as_str() {
216        "http" => 80,
217        "https" => 443,
218        _ => return None,
219    };
220    if rest.is_empty() || rest.contains(['/', '?', '#', '@', '\\', '%']) {
221        return None;
222    }
223    let authority = rest.parse::<Authority>().ok()?;
224    let suffix = rest.strip_prefix(authority.host())?;
225    let port = match suffix.strip_prefix(':') {
226        Some(digits) if digits.bytes().all(|b| b.is_ascii_digit()) => {
227            digits.parse::<u16>().ok().filter(|port| *port != 0)?
228        }
229        Some(_) => return None,
230        None if suffix.is_empty() => default_port,
231        None => return None,
232    };
233    let host = authority.host().to_ascii_lowercase();
234    if host.is_empty() {
235        None
236    } else if port == default_port {
237        Some(format!("{scheme}://{host}"))
238    } else {
239        Some(format!("{scheme}://{host}:{port}"))
240    }
241}
242
243/// The origin part of a `Referer` URL.
244fn referer_origin(referer: &str) -> Option<String> {
245    let (scheme, rest) = referer.split_once("://")?;
246    let end = rest.find(['/', '?', '#']).unwrap_or(rest.len());
247    parse_origin(&format!("{scheme}://{}", rest.get(..end)?))
248}
249
250impl_layer!(CsrfOrigin);
251
252#[cfg(test)]
253#[path = "csrf_tests.rs"]
254mod tests;