Skip to main content

rustlavel_auth/
csrf.rs

1//! The `csrf` middleware.
2//!
3//! A browser attaches cookies to *any* request it makes to your domain,
4//! including one triggered by a form on someone else's site. The session cookie
5//! alone therefore proves the visitor is logged in, not that they meant to
6//! submit this. The token closes that gap: it lives in the session and has to
7//! be echoed back in the request body or a header, which a cross-origin page
8//! cannot read and so cannot echo.
9//!
10//! ```ignore
11//! router.middleware(SessionManager::from_config(&config, store)?);
12//! router.middleware(Csrf::new().except("/webhooks/"));
13//! ```
14//!
15//! Order matters: the session has to be loaded before the token can be checked.
16
17use crate::constant_time_eq;
18use crate::middleware::{SessionExt, SessionHandle};
19use crate::session::Session;
20use rustlavel_core::{Error, Json};
21use rustlavel_http::handler::BoxFuture;
22use rustlavel_http::middleware::{Middleware, Next};
23use rustlavel_http::response::IntoResponse;
24use rustlavel_http::{Method, Request, Response, Status};
25
26/// The form field carrying the token, as Laravel names it.
27pub const TOKEN_FIELD: &str = Session::TOKEN_KEY;
28
29/// The header a fetch() or XHR client sends the token in instead.
30pub const TOKEN_HEADER: &str = "x-csrf-token";
31
32/// `419 Page Expired` — what a missing or stale token gets.
33///
34/// Laravel's choice, and a good one: the overwhelmingly common cause is a form
35/// left open until its session lapsed, not an attack, and "page expired" tells
36/// that visitor what to do. A 403 would suggest they lack permission.
37pub const REJECTED: Status = Status(419);
38
39/// Rejects unsafe requests that do not carry the session's CSRF token.
40#[derive(Debug, Clone, Default)]
41pub struct Csrf {
42    except: Vec<String>,
43}
44
45impl Csrf {
46    pub fn new() -> Self {
47        Csrf::default()
48    }
49
50    /// Exempt every path starting with `prefix`.
51    ///
52    /// For endpoints called by machines rather than browsers — an incoming
53    /// payment webhook has no session to hold a token, and authenticates
54    /// itself some other way.
55    pub fn except(mut self, prefix: impl Into<String>) -> Self {
56        self.except.push(prefix.into());
57        self
58    }
59
60    fn is_exempt(&self, path: &str) -> bool {
61        self.except.iter().any(|prefix| path.starts_with(prefix.as_str()))
62    }
63}
64
65/// Whether a method is safe, and so exempt.
66///
67/// These are the methods that are supposed to have no side effects. A GET that
68/// deletes something is a bug that CSRF protection cannot fix.
69fn is_safe(method: Method) -> bool {
70    matches!(method, Method::Get | Method::Head | Method::Options)
71}
72
73/// The token for this request, for a template to render into a form.
74pub fn token(request: &Request) -> Option<String> {
75    request.try_session().map(SessionHandle::token)
76}
77
78/// A ready-made hidden input: `{{ csrf::field(req) }}`.
79///
80/// The token is hexadecimal, so it needs no HTML escaping — but it is quoted
81/// anyway rather than relying on that.
82pub fn field(request: &Request) -> String {
83    match token(request) {
84        Some(token) => format!(r#"<input type="hidden" name="{TOKEN_FIELD}" value="{token}">"#),
85        None => String::new(),
86    }
87}
88
89impl Middleware for Csrf {
90    fn handle(&self, mut request: Request, next: Next) -> BoxFuture<Response> {
91        let middleware = self.clone();
92
93        Box::pin(async move {
94            let Some(session) = request.try_session().cloned() else {
95                return Error::msg(
96                    "the `csrf` middleware needs a session. Register the `session` middleware \
97                     before it: `router.middleware(SessionManager::from_config(&config, store)?)`.",
98                )
99                .into_response();
100            };
101
102            // Generating the token here — before the method check — is what
103            // makes it available to the GET that renders the form.
104            let expected = session.token();
105
106            if is_safe(request.method()) || middleware.is_exempt(request.path()) {
107                return next.run(request).await;
108            }
109
110            let from_body = request.input(TOKEN_FIELD);
111            let presented = match from_body {
112                Some(token) => Some(token),
113                None => request.header(TOKEN_HEADER).map(str::to_string),
114            };
115
116            // Constant time so a token cannot be recovered a character at a
117            // time by measuring how long the comparison takes.
118            let accepted = presented
119                .is_some_and(|token| constant_time_eq(token.as_bytes(), expected.as_bytes()));
120
121            if accepted { next.run(request).await } else { rejection(&request) }
122        })
123    }
124}
125
126fn rejection(request: &Request) -> Response {
127    if request.wants_json() {
128        return Response::new(REJECTED).with_json(Json::object([(
129            "message",
130            Json::from("CSRF token mismatch."),
131        )]));
132    }
133
134    Response::new(REJECTED).with_html(format!(
135        "<h1>{} {}</h1><p>Your session has expired. Please refresh the page and try again.</p>",
136        REJECTED.code(),
137        REJECTED.reason()
138    ))
139}
140
141#[cfg(test)]
142mod tests {
143    use super::*;
144    use crate::key::AppKey;
145    use crate::middleware::SessionManager;
146    use crate::store::MemoryStore;
147    use rustlavel_http::{Router, TestClient, TestResponse};
148
149    fn router() -> Router {
150        let mut router = Router::new();
151        router.middleware(SessionManager::new(&AppKey::from_bytes([5u8; 32]), MemoryStore::new()));
152        router.middleware(Csrf::new().except("/webhooks/"));
153
154        router.get("/form", |request: Request| async move { field(&request) });
155        router.post("/posts", |_request: Request| async { "created" });
156        router.put("/posts/1", |_request: Request| async { "updated" });
157        router.patch("/posts/1", |_request: Request| async { "patched" });
158        router.delete("/posts/1", |_request: Request| async { "deleted" });
159        router.post("/webhooks/stripe", |_request: Request| async { "accepted" });
160        router
161    }
162
163    /// The name=value part of the response's session cookie.
164    fn cookie_of(response: &TestResponse) -> String {
165        response
166            .header("set-cookie")
167            .expect("the session middleware should have set a cookie")
168            .split(';')
169            .next()
170            .unwrap()
171            .to_string()
172    }
173
174    /// Visit the form page, returning its token and session cookie.
175    async fn open_form(client: &TestClient) -> (String, String) {
176        let response = client.get("/form").await.assert_ok();
177        let body = response.body();
178        let token = body
179            .split("value=\"")
180            .nth(1)
181            .and_then(|rest| rest.split('"').next())
182            .expect("the form should contain a token")
183            .to_string();
184
185        (token, cookie_of(&response))
186    }
187
188    #[tokio::test]
189    async fn a_get_request_passes_through_and_is_handed_a_token() {
190        let client = TestClient::new(router());
191        let (token, _) = open_form(&client).await;
192
193        assert_eq!(token.len(), 64, "the token should be 32 bytes of entropy in hex");
194        assert!(token.chars().all(|c| c.is_ascii_hexdigit()));
195    }
196
197    #[tokio::test]
198    async fn a_post_without_a_token_is_rejected_with_419() {
199        TestClient::new(router())
200            .post("/posts", &[("title", "hello")])
201            .await
202            .assert_status(419)
203            .assert_see("expired");
204    }
205
206    #[tokio::test]
207    async fn a_post_with_the_session_token_is_accepted() {
208        let client = TestClient::new(router());
209        let (token, cookie) = open_form(&client).await;
210
211        let response = client
212            .send(
213                Request::new(Method::Post, "/posts")
214                    .with_header("cookie", cookie)
215                    .with_form(&[("title", "hello"), (TOKEN_FIELD, &token)]),
216            )
217            .await;
218
219        response.assert_ok().assert_see("created");
220    }
221
222    #[tokio::test]
223    async fn the_token_may_arrive_in_a_header_instead() {
224        let client = TestClient::new(router());
225        let (token, cookie) = open_form(&client).await;
226
227        client
228            .send(
229                Request::new(Method::Post, "/posts")
230                    .with_header("cookie", cookie)
231                    .with_header(TOKEN_HEADER, token)
232                    .with_json(Json::object([("title", "hello".into())])),
233            )
234            .await
235            .assert_ok();
236    }
237
238    #[tokio::test]
239    async fn a_token_from_another_session_is_rejected() {
240        // Two clients, because two sessions means two browsers: the test client
241        // keeps a cookie jar, so one client would simply reuse its own session.
242        let client = TestClient::new(router());
243        let other_browser = TestClient::new(router());
244
245        let (token, _) = open_form(&client).await;
246        let (_, other_cookie) = open_form(&other_browser).await;
247
248        client
249            .send(
250                Request::new(Method::Post, "/posts")
251                    .with_header("cookie", other_cookie)
252                    .with_form(&[(TOKEN_FIELD, &token)]),
253            )
254            .await
255            .assert_status(419);
256    }
257
258    #[tokio::test]
259    async fn a_wrong_or_truncated_token_is_rejected() {
260        let client = TestClient::new(router());
261        let (token, cookie) = open_form(&client).await;
262
263        let candidates = [
264            String::new(),
265            token[..token.len() - 1].to_string(),
266            format!("{token}0"),
267            "a".repeat(64),
268        ];
269
270        for candidate in &candidates {
271            client
272                .send(
273                    Request::new(Method::Post, "/posts")
274                        .with_header("cookie", cookie.clone())
275                        .with_form(&[(TOKEN_FIELD, candidate.as_str())]),
276                )
277                .await
278                .assert_status(419);
279        }
280    }
281
282    #[tokio::test]
283    async fn every_state_changing_method_is_guarded() {
284        let client = TestClient::new(router());
285
286        client.send(Request::new(Method::Put, "/posts/1")).await.assert_status(419);
287        client.send(Request::new(Method::Patch, "/posts/1")).await.assert_status(419);
288        client.delete("/posts/1").await.assert_status(419);
289    }
290
291    #[tokio::test]
292    async fn exempt_paths_are_left_alone() {
293        TestClient::new(router())
294            .post("/webhooks/stripe", &[("event", "paid")])
295            .await
296            .assert_ok()
297            .assert_see("accepted");
298    }
299
300    #[tokio::test]
301    async fn a_json_client_is_rejected_with_json() {
302        TestClient::new(router())
303            .post_json("/posts", Json::object([("title", "hello".into())]))
304            .await
305            .assert_status(419)
306            .assert_json("message", "CSRF token mismatch.");
307    }
308
309    #[tokio::test]
310    async fn the_token_is_stable_for_the_life_of_the_session() {
311        let client = TestClient::new(router());
312        let (first, cookie) = open_form(&client).await;
313
314        let response = client
315            .send(Request::new(Method::Get, "/form").with_header("cookie", cookie))
316            .await;
317        let second = response.body();
318
319        assert!(second.contains(&first), "the token must not change between page loads");
320    }
321
322    #[tokio::test]
323    async fn without_the_session_middleware_the_error_says_so() {
324        let mut router = Router::new();
325        router.middleware(Csrf::new());
326        router.get("/", |_request: Request| async { "ok" });
327
328        // The failure surfaces as a server error; the explanation reaches the
329        // developer through the error page or the log, depending on `app.debug`.
330        TestClient::new(router).get("/").await.assert_status(500);
331    }
332
333    #[test]
334    fn safe_methods_are_the_ones_with_no_side_effects() {
335        assert!(is_safe(Method::Get));
336        assert!(is_safe(Method::Head));
337        assert!(is_safe(Method::Options));
338        assert!(!is_safe(Method::Post));
339        assert!(!is_safe(Method::Put));
340        assert!(!is_safe(Method::Patch));
341        assert!(!is_safe(Method::Delete));
342    }
343}