1use 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
21const VERSION: u8 = 1;
23const MAX_BLOB: usize = 4096;
26const CSRF_HEADER: &str = "x-csrf-token";
27pub(crate) const CSRF_FIELD: &str = "_csrf";
28
29#[derive(Debug, Clone, Serialize, Deserialize)]
33pub(crate) struct Flash {
34 pub(crate) level: FlashLevel,
35 pub(crate) text: Text,
36 #[serde(default)]
38 pub(crate) sticky: bool,
39}
40
41#[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 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#[derive(Debug, Clone, Serialize, Deserialize)]
63struct SessionData {
64 #[serde(default = "default_version")]
66 v: u8,
67 #[serde(default)]
69 csrf: String,
70 #[serde(default)]
72 flash: Vec<Flash>,
73 #[serde(default)]
75 lists: Vec<(String, String)>,
76}
77
78const 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 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#[derive(Clone)]
120pub struct SessionHandle {
121 inner: Arc<Mutex<Inner>>,
122}
123
124impl SessionHandle {
125 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 pub(crate) fn csrf_token(&self) -> String {
141 self.inner.lock().unwrap().data.csrf.clone()
142 }
143
144 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 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 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 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 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 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
213pub(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
224pub(crate) fn is_safe_method(m: &Method) -> bool {
227 matches!(*m, Method::GET | Method::HEAD | Method::OPTIONS) || m.as_str() == "QUERY"
228}
229
230fn 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
242pub(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
251pub(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
274pub(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
285pub(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
293pub(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#[derive(Clone, Default)]
318pub(crate) struct CsrfPending(std::sync::Arc<std::sync::atomic::AtomicBool>);
319
320impl CsrfPending {
321 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
331pub(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 #[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 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 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 #[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 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}