Skip to main content

topcoat_session/
origin.rs

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