Skip to main content

laterite_admin/
session.rs

1//! The admin session blob and CSRF protection.
2//!
3//! Auth stores an opaque per-session string; this is the admin surface's typed
4//! view of it: a CSRF synchronizer token and flash messages. A [`SessionHandle`]
5//! (a request extension) exposes reads and mutations, and `require_auth` writes
6//! the blob back only when it changed, so an unchanged request adds no write.
7//!
8//! CSRF is layered: [`origin_ok`] is the primary gate (same-origin check via
9//! `Sec-Fetch-Site`/`Origin`), and [`token_matches`] compares a per-session
10//! token against the one submitted in a form field or header. Safe methods
11//! ([`is_safe_method`]) are exempt.
12
13use std::fmt::Write as _;
14use std::sync::{Arc, Mutex};
15
16use axum::http::{HeaderMap, Method};
17use laterite_core::Text;
18use rand::RngCore;
19use serde::{Deserialize, Serialize};
20
21/// Current schema version of the session blob.
22const VERSION: u8 = 1;
23/// A blob larger than this is treated as corrupt and ignored (a session blob is
24/// a token plus a few short messages; anything bigger is not ours).
25const MAX_BLOB: usize = 4096;
26const CSRF_HEADER: &str = "x-csrf-token";
27pub(crate) const CSRF_FIELD: &str = "_csrf";
28
29/// A user-facing flash message, shown once on the next full-page render. The text
30/// is a locale-free [`Text`] so it survives the redirect round-trip in the session
31/// blob and is localized when the shell renders it.
32#[derive(Debug, Clone, Serialize, Deserialize)]
33pub(crate) struct Flash {
34    pub(crate) level: FlashLevel,
35    pub(crate) text: Text,
36    /// Stays until dismissed, for a consequence the operator should read.
37    #[serde(default)]
38    pub(crate) sticky: bool,
39}
40
41/// The severity of a [`Flash`], mapped to a style class by the template.
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
43#[serde(rename_all = "snake_case")]
44pub enum FlashLevel {
45    Success,
46    Error,
47    Info,
48}
49
50impl FlashLevel {
51    /// The CSS modifier the flash renders with.
52    pub(crate) fn class(self) -> &'static str {
53        match self {
54            FlashLevel::Success => "is-success",
55            FlashLevel::Error => "is-error",
56            FlashLevel::Info => "is-info",
57        }
58    }
59}
60
61/// The admin's typed view of the opaque session blob.
62#[derive(Debug, Clone, Serialize, Deserialize)]
63struct SessionData {
64    /// Blob schema version, for forward-compatible reads.
65    #[serde(default = "default_version")]
66    v: u8,
67    /// The CSRF synchronizer token.
68    #[serde(default)]
69    csrf: String,
70    /// Queued flash messages.
71    #[serde(default)]
72    flash: Vec<Flash>,
73    /// The query each list was last shown with, newest last: `(path, query)`.
74    #[serde(default)]
75    lists: Vec<(String, String)>,
76}
77
78/// How many lists a session remembers, and how long a query it keeps: the blob
79/// is small by design, and a query past this is a filter nobody will retype.
80const REMEMBERED_LISTS: usize = 8;
81const REMEMBERED_QUERY: usize = 512;
82
83fn default_version() -> u8 {
84    VERSION
85}
86
87impl Default for SessionData {
88    fn default() -> Self {
89        Self {
90            v: VERSION,
91            csrf: String::new(),
92            flash: Vec::new(),
93            lists: Vec::new(),
94        }
95    }
96}
97
98impl SessionData {
99    /// Parses the stored blob leniently: an absent, oversized, or unparseable
100    /// blob degrades to an empty session, never an error, so a corrupt blob can
101    /// never lock an operator out.
102    fn parse(raw: Option<&str>) -> Self {
103        match raw {
104            Some(s) if s.len() <= MAX_BLOB => serde_json::from_str(s).unwrap_or_default(),
105            _ => Self::default(),
106        }
107    }
108}
109
110struct Inner {
111    data: SessionData,
112    dirty: bool,
113}
114
115/// A cheap-to-clone handle to the current session's typed state, shared between
116/// the auth middleware and the handler through request extensions. Mutations set
117/// a dirty flag; the middleware persists the blob after the handler runs only
118/// when it is dirty.
119#[derive(Clone)]
120pub struct SessionHandle {
121    inner: Arc<Mutex<Inner>>,
122}
123
124impl SessionHandle {
125    /// Builds a handle from the stored blob, minting a CSRF token if the session
126    /// has none yet (a fresh login). Minting marks the handle dirty so the token
127    /// is persisted on this request.
128    pub(crate) fn from_blob(raw: Option<&str>) -> Self {
129        let mut data = SessionData::parse(raw);
130        let dirty = data.csrf.is_empty();
131        if dirty {
132            data.csrf = generate_token();
133        }
134        Self {
135            inner: Arc::new(Mutex::new(Inner { data, dirty })),
136        }
137    }
138
139    /// The session's CSRF token.
140    pub(crate) fn csrf_token(&self) -> String {
141        self.inner.lock().unwrap().data.csrf.clone()
142    }
143
144    /// Queues a flash message for the next full-page render. The text is a
145    /// [`Text`], built with `t!`, so it localizes at render.
146    pub fn push_flash(&self, level: FlashLevel, text: Text) {
147        let mut g = self.inner.lock().unwrap();
148        g.data.flash.push(Flash {
149            level,
150            text,
151            sticky: false,
152        });
153        g.dirty = true;
154    }
155
156    /// Queues a message that stays until dismissed, for a consequence the
157    /// operator should read rather than glance at, such as a security action.
158    pub fn push_flash_sticky(&self, level: FlashLevel, text: Text) {
159        let mut g = self.inner.lock().unwrap();
160        g.data.flash.push(Flash {
161            level,
162            text,
163            sticky: true,
164        });
165        g.dirty = true;
166    }
167
168    /// The query the list at `path` was last shown with, if the session holds one.
169    pub(crate) fn remembered_list(&self, path: &str) -> Option<String> {
170        let g = self.inner.lock().unwrap();
171        g.data
172            .lists
173            .iter()
174            .find(|(p, _)| p == path)
175            .map(|(_, q)| q.clone())
176    }
177
178    /// Remembers `query` for the list at `path`, or forgets the list when
179    /// `query` is `None`. The newest lists are kept.
180    pub(crate) fn remember_list(&self, path: &str, query: Option<&str>) {
181        let mut g = self.inner.lock().unwrap();
182        let before = g.data.lists.clone();
183        g.data.lists.retain(|(p, _)| p != path);
184        if let Some(query) = query.filter(|q| q.len() <= REMEMBERED_QUERY) {
185            g.data.lists.push((path.to_string(), query.to_string()));
186            if g.data.lists.len() > REMEMBERED_LISTS {
187                g.data.lists.remove(0);
188            }
189        }
190        if g.data.lists != before {
191            g.dirty = true;
192        }
193    }
194
195    /// Takes and clears the queued flash messages.
196    pub(crate) fn take_flash(&self) -> Vec<Flash> {
197        let mut g = self.inner.lock().unwrap();
198        if g.data.flash.is_empty() {
199            return Vec::new();
200        }
201        g.dirty = true;
202        std::mem::take(&mut g.data.flash)
203    }
204
205    /// The serialized blob to persist, or `None` if nothing changed this request.
206    pub(crate) fn dirty_blob(&self) -> Option<String> {
207        let g = self.inner.lock().unwrap();
208        g.dirty
209            .then(|| serde_json::to_string(&g.data).unwrap_or_default())
210    }
211}
212
213/// Generates a 256-bit random token as lowercase hex.
214pub(crate) fn generate_token() -> String {
215    let mut bytes = [0u8; 32];
216    rand::rngs::OsRng.fill_bytes(&mut bytes);
217    let mut out = String::with_capacity(64);
218    for b in bytes {
219        let _ = write!(out, "{b:02x}");
220    }
221    out
222}
223
224/// Whether the method is safe (read-only) and therefore CSRF-exempt: GET, HEAD,
225/// OPTIONS, and the QUERY method (a safe, idempotent read that carries a body).
226pub(crate) fn is_safe_method(m: &Method) -> bool {
227    matches!(*m, Method::GET | Method::HEAD | Method::OPTIONS) || m.as_str() == "QUERY"
228}
229
230/// Constant-time equality for two byte slices.
231fn ct_eq(a: &[u8], b: &[u8]) -> bool {
232    if a.len() != b.len() {
233        return false;
234    }
235    let mut diff = 0u8;
236    for (x, y) in a.iter().zip(b) {
237        diff |= x ^ y;
238    }
239    diff == 0
240}
241
242/// Whether the submitted CSRF token matches the session's. A missing submitted
243/// token, or an empty session token, never matches.
244pub(crate) fn token_matches(expected: &str, submitted: Option<&str>) -> bool {
245    match submitted {
246        Some(t) if !expected.is_empty() => ct_eq(expected.as_bytes(), t.as_bytes()),
247        _ => false,
248    }
249}
250
251/// The primary CSRF gate for a state-changing request: it must come from our own
252/// origin. `Sec-Fetch-Site: same-origin|none` passes; otherwise the `Origin`
253/// header must equal `expected_origin`. A request carrying neither header is
254/// rejected (a same-origin browser always sends at least one on a mutation).
255/// An empty `expected_origin` falls back to the request `Host` (dev convenience;
256/// production should set `app.url`).
257pub(crate) fn origin_ok(headers: &HeaderMap, expected_origin: &str) -> bool {
258    if let Some(site) = headers.get("sec-fetch-site").and_then(|v| v.to_str().ok()) {
259        return matches!(site, "same-origin" | "none");
260    }
261    let origin = match headers.get("origin").and_then(|v| v.to_str().ok()) {
262        Some(o) => o.trim_end_matches('/'),
263        None => return false,
264    };
265    if !expected_origin.is_empty() {
266        return origin == expected_origin.trim_end_matches('/');
267    }
268    match headers.get("host").and_then(|v| v.to_str().ok()) {
269        Some(host) => origin == format!("http://{host}") || origin == format!("https://{host}"),
270        None => false,
271    }
272}
273
274/// Extracts a submitted CSRF token: the [`CSRF_HEADER`] header if present, else
275/// the [`CSRF_FIELD`] field of an urlencoded form body.
276pub(crate) fn submitted_token(headers: &HeaderMap, body: &[u8]) -> Option<String> {
277    if let Some(token) = header_token(headers) {
278        return Some(token);
279    }
280    form_urlencoded::parse(body)
281        .find(|(k, _)| k == CSRF_FIELD)
282        .map(|(_, v)| v.into_owned())
283}
284
285/// The token from the request header, which is how scripted requests send it.
286pub(crate) fn header_token(headers: &HeaderMap) -> Option<String> {
287    headers
288        .get(CSRF_HEADER)
289        .and_then(|v| v.to_str().ok())
290        .map(str::to_string)
291}
292
293/// The token from the URL's query string.
294///
295/// The fallback for a file upload with scripting off. A `multipart/form-data`
296/// body is not buffered (a file would be truncated by the form limit, and
297/// holding it in memory to read one field defeats streaming it), and a plain
298/// HTML form cannot set a header, so the form's action carries the token.
299///
300/// A token in a query string is visible to logs and to a `Referer`, which is why
301/// it is the fallback and not the rule. It is defence in depth rather than the
302/// defence: the origin gate ([`origin_ok`]) runs first on every state-changing
303/// request, and the token is per-session and useless without the session cookie.
304pub(crate) fn query_token(query: Option<&str>) -> Option<String> {
305    form_urlencoded::parse(query.unwrap_or_default().as_bytes())
306        .find(|(k, _)| k == CSRF_FIELD)
307        .map(|(_, v)| v.into_owned())
308}
309
310/// A CSRF check the guard could not make itself, handed to the handler.
311///
312/// A file upload carries its token in the body, and the guard does not read that
313/// body. Rather than trust a handler to remember, the guard marks the check
314/// outstanding, and refuses the response if it is still outstanding when the
315/// handler returns. Forgetting is therefore not a silent hole: the route simply
316/// stops working, loudly, the first time it is used.
317#[derive(Clone, Default)]
318pub(crate) struct CsrfPending(std::sync::Arc<std::sync::atomic::AtomicBool>);
319
320impl CsrfPending {
321    /// Called by the extractor once it has checked the token.
322    pub(crate) fn satisfy(&self) {
323        self.0.store(true, std::sync::atomic::Ordering::Release);
324    }
325
326    pub(crate) fn was_satisfied(&self) -> bool {
327        self.0.load(std::sync::atomic::Ordering::Acquire)
328    }
329}
330
331/// Whether this request carries a body this guard must not buffer.
332pub(crate) fn is_multipart(headers: &HeaderMap) -> bool {
333    headers
334        .get(axum::http::header::CONTENT_TYPE)
335        .and_then(|v| v.to_str().ok())
336        .is_some_and(|ct| {
337            ct.trim_start()
338                .to_ascii_lowercase()
339                .starts_with("multipart/form-data")
340        })
341}
342
343#[cfg(test)]
344mod tests {
345    use super::*;
346
347    #[test]
348    fn corrupt_blob_degrades_to_empty_session() {
349        assert!(SessionData::parse(Some("not json")).csrf.is_empty());
350        assert!(SessionData::parse(Some(&"x".repeat(MAX_BLOB + 1)))
351            .csrf
352            .is_empty());
353        assert!(SessionData::parse(None).flash.is_empty());
354    }
355
356    #[test]
357    fn a_fresh_session_mints_a_token_and_is_dirty() {
358        let h = SessionHandle::from_blob(None);
359        assert_eq!(h.csrf_token().len(), 64);
360        assert!(h.dirty_blob().is_some());
361    }
362
363    #[test]
364    fn an_existing_token_is_kept_and_clean() {
365        let blob = format!(r#"{{"v":1,"csrf":"{}"}}"#, "a".repeat(64));
366        let h = SessionHandle::from_blob(Some(&blob));
367        assert_eq!(h.csrf_token(), "a".repeat(64));
368        assert!(h.dirty_blob().is_none());
369    }
370
371    #[test]
372    fn flash_round_trips_and_marks_dirty() {
373        let h = SessionHandle::from_blob(Some(&format!(r#"{{"csrf":"{}"}}"#, "a".repeat(64))));
374        h.push_flash(FlashLevel::Success, Text::dynamic("Saved"));
375        assert!(h.dirty_blob().is_some());
376        let taken = h.take_flash();
377        assert_eq!(taken.len(), 1);
378        assert_eq!(taken[0].text.source(), "Saved");
379        assert!(h.take_flash().is_empty());
380    }
381
382    #[test]
383    fn token_match_is_exact_and_rejects_missing() {
384        assert!(token_matches("abc", Some("abc")));
385        assert!(!token_matches("abc", Some("abd")));
386        assert!(!token_matches("abc", None));
387        assert!(!token_matches("", Some("")));
388    }
389
390    #[test]
391    fn safe_methods_are_exempt() {
392        assert!(is_safe_method(&Method::GET));
393        assert!(is_safe_method(&Method::from_bytes(b"QUERY").unwrap()));
394        assert!(!is_safe_method(&Method::POST));
395        assert!(!is_safe_method(&Method::DELETE));
396    }
397
398    /// A file upload cannot carry its token in a buffered body, so the guard
399    /// takes it from the header (scripted) or the action's query string (not).
400    #[test]
401    fn an_upload_carries_its_token_beside_the_body() {
402        let mut h = HeaderMap::new();
403        h.insert(
404            "content-type",
405            "multipart/form-data; boundary=x".parse().unwrap(),
406        );
407        assert!(
408            is_multipart(&h),
409            "an upload must be recognised before buffering"
410        );
411
412        // Scripted: the header.
413        let mut with_header = h.clone();
414        with_header.insert(CSRF_HEADER, "tok".parse().unwrap());
415        assert_eq!(header_token(&with_header).as_deref(), Some("tok"));
416
417        // Scriptless: the form action's query string.
418        assert_eq!(
419            query_token(Some("_csrf=tok&other=1")).as_deref(),
420            Some("tok")
421        );
422        assert_eq!(query_token(Some("other=1")), None);
423        assert_eq!(query_token(None), None);
424    }
425
426    /// The content type decides, and it arrives with a boundary and any casing.
427    #[test]
428    fn only_a_multipart_body_skips_buffering() {
429        for (value, expected) in [
430            ("multipart/form-data; boundary=abc", true),
431            ("MULTIPART/FORM-DATA; boundary=abc", true),
432            ("application/x-www-form-urlencoded", false),
433            ("application/json", false),
434            ("text/plain", false),
435        ] {
436            let mut h = HeaderMap::new();
437            h.insert("content-type", value.parse().unwrap());
438            assert_eq!(is_multipart(&h), expected, "{value}");
439        }
440        assert!(
441            !is_multipart(&HeaderMap::new()),
442            "no content type is not an upload"
443        );
444    }
445
446    #[test]
447    fn origin_gate_accepts_same_origin_and_rejects_cross() {
448        let mut h = HeaderMap::new();
449        h.insert("sec-fetch-site", "same-origin".parse().unwrap());
450        assert!(origin_ok(&h, "https://acme.test"));
451
452        let mut h = HeaderMap::new();
453        h.insert("sec-fetch-site", "cross-site".parse().unwrap());
454        assert!(!origin_ok(&h, "https://acme.test"));
455
456        let mut h = HeaderMap::new();
457        h.insert("origin", "https://acme.test".parse().unwrap());
458        assert!(origin_ok(&h, "https://acme.test"));
459        assert!(!origin_ok(&h, "https://evil.test"));
460
461        // Neither header on a mutation: reject.
462        assert!(!origin_ok(&HeaderMap::new(), "https://acme.test"));
463    }
464
465    #[test]
466    fn submitted_token_reads_header_then_field() {
467        let mut h = HeaderMap::new();
468        h.insert(CSRF_HEADER, "from-header".parse().unwrap());
469        assert_eq!(submitted_token(&h, b"").as_deref(), Some("from-header"));
470
471        let body = b"username=root&_csrf=from-field&x=1";
472        assert_eq!(
473            submitted_token(&HeaderMap::new(), body).as_deref(),
474            Some("from-field")
475        );
476        assert_eq!(submitted_token(&HeaderMap::new(), b"a=b"), None);
477    }
478}