Skip to main content

topcoat_session/
origin.rs

1use topcoat_core::context::{Cx, CxBuilder};
2use topcoat_router::{
3    Body, Layer, LayerFuture, Method, Next, Path,
4    error::{ForbiddenError, forbidden},
5    header, headers, method, uri,
6};
7
8use crate::config;
9
10/// Verifies that the current request is not a cross-site request forgery
11/// (CSRF).
12///
13/// Requests with safe methods (`GET`, `HEAD`, `OPTIONS`) always pass. For
14/// every other method, the browser-provided `Sec-Fetch-Site` header must be
15/// `same-origin` or `none` (a direct navigation); `same-site` is rejected, so
16/// sibling subdomains cannot forge requests. For browsers that predate
17/// `Sec-Fetch-Site`, the `Origin` header's host is compared against the
18/// request's own host instead. Requests carrying neither header pass: they
19/// come from non-browser clients, which do not attach cookies ambiently.
20///
21/// Origins trusted with
22/// [`SessionConfigBuilder::trust_origin`](crate::SessionConfigBuilder::trust_origin) always pass.
23/// The [`OriginLayer`] registered by the router's `sessions` extension method applies this check to
24/// every request; call it directly only where that layer does not.
25///
26/// # Errors
27///
28/// Returns a [`ForbiddenError`] (HTTP 403) when the request is a
29/// state-changing cross-origin request.
30pub fn verify_origin(cx: &Cx) -> Result<(), ForbiddenError> {
31    let headers = headers(cx);
32    let sec_fetch_site = headers
33        .get("sec-fetch-site")
34        .and_then(|value| value.to_str().ok());
35    let origin = headers
36        .get(header::ORIGIN)
37        .and_then(|value| value.to_str().ok());
38    // `Authority::as_str` is not nameable here without a direct `http` dependency.
39    #[allow(clippy::redundant_closure_for_method_calls)]
40    let host = headers
41        .get(header::HOST)
42        .and_then(|value| value.to_str().ok())
43        .or_else(|| uri(cx).authority().map(|authority| authority.as_str()));
44
45    if allowed(
46        method(cx),
47        sec_fetch_site,
48        origin,
49        host,
50        &config(cx).trusted_origins,
51    ) {
52        Ok(())
53    } else {
54        Err(forbidden())
55    }
56}
57
58/// The decision behind [`verify_origin`], over the request's relevant parts.
59fn allowed(
60    method: &Method,
61    sec_fetch_site: Option<&str>,
62    origin: Option<&str>,
63    host: Option<&str>,
64    trusted_origins: &[String],
65) -> bool {
66    // Safe methods must not change state, so they need no protection.
67    if matches!(*method, Method::GET | Method::HEAD | Method::OPTIONS) {
68        return true;
69    }
70    // An explicitly trusted origin passes regardless of how the browser
71    // classified the request.
72    if origin.is_some_and(|origin| {
73        trusted_origins
74            .iter()
75            .any(|trusted| trusted.eq_ignore_ascii_case(origin))
76    }) {
77        return true;
78    }
79    // A modern browser declares how the request's initiator relates to its
80    // target.
81    if let Some(site) = sec_fetch_site {
82        return matches!(site, "same-origin" | "none");
83    }
84    // Older browsers send only `Origin`; compare its host against the
85    // request's own. `Origin: null` has no host and never matches.
86    if let Some(origin) = origin {
87        return origin_host(origin)
88            .zip(host)
89            .is_some_and(|(origin_host, host)| origin_host.eq_ignore_ascii_case(host));
90    }
91    // Neither header: not a browser, so no ambient cookies to forge with.
92    true
93}
94
95/// Extracts the `host[:port]` part of a serialized origin.
96fn origin_host(origin: &str) -> Option<&str> {
97    origin.split_once("://").map(|(_, host)| host)
98}
99
100/// A router layer that rejects state-changing cross-origin requests, as a
101/// defense against cross-site request forgery (CSRF).
102///
103/// Every request passing through it is checked with [`verify_origin`].
104/// The router's `sessions` extension method registers it unless
105/// [`SessionConfigBuilder::dangerous_disable_origin_verification`](crate::SessionConfigBuilder::dangerous_disable_origin_verification)
106/// is set.
107#[derive(Debug, Clone, Copy, Default)]
108pub struct OriginLayer;
109
110impl OriginLayer {
111    /// Creates an origin verification layer.
112    #[must_use]
113    pub const fn new() -> Self {
114        Self
115    }
116}
117
118impl Layer for OriginLayer {
119    fn path(&self) -> &Path {
120        Path::new("/")
121    }
122
123    fn handle<'a>(&'a self, cx: &'a mut CxBuilder, body: Body, next: Next<'a>) -> LayerFuture<'a> {
124        match verify_origin(cx) {
125            Ok(()) => next.run(cx, body),
126            Err(error) => Box::pin(async move { Err(error.into()) }),
127        }
128    }
129}
130
131#[cfg(test)]
132mod tests {
133    use super::*;
134
135    const NO_TRUST: &[String] = &[];
136
137    fn post_allowed(
138        sec_fetch_site: Option<&str>,
139        origin: Option<&str>,
140        host: Option<&str>,
141    ) -> bool {
142        allowed(&Method::POST, sec_fetch_site, origin, host, NO_TRUST)
143    }
144
145    #[test]
146    fn safe_methods_pass_even_cross_site() {
147        for method in [Method::GET, Method::HEAD, Method::OPTIONS] {
148            assert!(allowed(
149                &method,
150                Some("cross-site"),
151                Some("https://evil.example"),
152                Some("app.example"),
153                NO_TRUST,
154            ));
155        }
156    }
157
158    #[test]
159    fn same_origin_and_direct_navigation_pass() {
160        assert!(post_allowed(Some("same-origin"), None, Some("app.example")));
161        assert!(post_allowed(Some("none"), None, Some("app.example")));
162    }
163
164    #[test]
165    fn same_site_and_cross_site_are_rejected() {
166        // `same-site` is rejected deliberately: a sibling subdomain must not
167        // be able to forge requests.
168        assert!(!post_allowed(
169            Some("same-site"),
170            Some("https://evil.app.example"),
171            Some("app.example"),
172        ));
173        assert!(!post_allowed(
174            Some("cross-site"),
175            Some("https://evil.example"),
176            Some("app.example"),
177        ));
178    }
179
180    #[test]
181    fn trusted_origins_pass_even_cross_site() {
182        let trusted = vec!["https://accounts.example".to_owned()];
183        assert!(allowed(
184            &Method::POST,
185            Some("cross-site"),
186            Some("https://accounts.example"),
187            Some("app.example"),
188            &trusted,
189        ));
190        // Trust is keyed on the `Origin` header; without one the request is
191        // still classified by `Sec-Fetch-Site`.
192        assert!(!allowed(
193            &Method::POST,
194            Some("cross-site"),
195            None,
196            Some("app.example"),
197            &trusted,
198        ));
199    }
200
201    #[test]
202    fn origin_fallback_compares_hosts() {
203        assert!(post_allowed(
204            None,
205            Some("https://app.example"),
206            Some("app.example"),
207        ));
208        // Hosts are case-insensitive, and any explicit port must match.
209        assert!(post_allowed(
210            None,
211            Some("https://App.Example:8443"),
212            Some("app.example:8443"),
213        ));
214        assert!(!post_allowed(
215            None,
216            Some("https://evil.example"),
217            Some("app.example"),
218        ));
219        assert!(!post_allowed(
220            None,
221            Some("https://app.example:8443"),
222            Some("app.example"),
223        ));
224    }
225
226    #[test]
227    fn opaque_origin_is_rejected() {
228        assert!(!post_allowed(None, Some("null"), Some("app.example")));
229    }
230
231    #[test]
232    fn origin_without_a_request_host_is_rejected() {
233        assert!(!post_allowed(None, Some("https://app.example"), None));
234    }
235
236    #[test]
237    fn non_browser_requests_pass() {
238        assert!(post_allowed(None, None, Some("app.example")));
239        assert!(post_allowed(None, None, None));
240    }
241}