laterite_admin/
session.rs1use 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";
27const 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}
37
38#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
40#[serde(rename_all = "snake_case")]
41pub enum FlashLevel {
42 Success,
43 Error,
44 Info,
45}
46
47impl FlashLevel {
48 pub(crate) fn class(self) -> &'static str {
50 match self {
51 FlashLevel::Success => "is-success",
52 FlashLevel::Error => "is-error",
53 FlashLevel::Info => "is-info",
54 }
55 }
56}
57
58#[derive(Debug, Clone, Serialize, Deserialize)]
60struct SessionData {
61 #[serde(default = "default_version")]
63 v: u8,
64 #[serde(default)]
66 csrf: String,
67 #[serde(default)]
69 flash: Vec<Flash>,
70}
71
72fn default_version() -> u8 {
73 VERSION
74}
75
76impl Default for SessionData {
77 fn default() -> Self {
78 Self {
79 v: VERSION,
80 csrf: String::new(),
81 flash: Vec::new(),
82 }
83 }
84}
85
86impl SessionData {
87 fn parse(raw: Option<&str>) -> Self {
91 match raw {
92 Some(s) if s.len() <= MAX_BLOB => serde_json::from_str(s).unwrap_or_default(),
93 _ => Self::default(),
94 }
95 }
96}
97
98struct Inner {
99 data: SessionData,
100 dirty: bool,
101}
102
103#[derive(Clone)]
108pub struct SessionHandle {
109 inner: Arc<Mutex<Inner>>,
110}
111
112impl SessionHandle {
113 pub(crate) fn from_blob(raw: Option<&str>) -> Self {
117 let mut data = SessionData::parse(raw);
118 let dirty = data.csrf.is_empty();
119 if dirty {
120 data.csrf = generate_token();
121 }
122 Self {
123 inner: Arc::new(Mutex::new(Inner { data, dirty })),
124 }
125 }
126
127 pub(crate) fn csrf_token(&self) -> String {
129 self.inner.lock().unwrap().data.csrf.clone()
130 }
131
132 pub fn push_flash(&self, level: FlashLevel, text: Text) {
135 let mut g = self.inner.lock().unwrap();
136 g.data.flash.push(Flash { level, text });
137 g.dirty = true;
138 }
139
140 pub(crate) fn take_flash(&self) -> Vec<Flash> {
142 let mut g = self.inner.lock().unwrap();
143 if g.data.flash.is_empty() {
144 return Vec::new();
145 }
146 g.dirty = true;
147 std::mem::take(&mut g.data.flash)
148 }
149
150 pub(crate) fn dirty_blob(&self) -> Option<String> {
152 let g = self.inner.lock().unwrap();
153 g.dirty
154 .then(|| serde_json::to_string(&g.data).unwrap_or_default())
155 }
156}
157
158pub(crate) fn generate_token() -> String {
160 let mut bytes = [0u8; 32];
161 rand::rngs::OsRng.fill_bytes(&mut bytes);
162 let mut out = String::with_capacity(64);
163 for b in bytes {
164 let _ = write!(out, "{b:02x}");
165 }
166 out
167}
168
169pub(crate) fn is_safe_method(m: &Method) -> bool {
172 matches!(*m, Method::GET | Method::HEAD | Method::OPTIONS) || m.as_str() == "QUERY"
173}
174
175fn ct_eq(a: &[u8], b: &[u8]) -> bool {
177 if a.len() != b.len() {
178 return false;
179 }
180 let mut diff = 0u8;
181 for (x, y) in a.iter().zip(b) {
182 diff |= x ^ y;
183 }
184 diff == 0
185}
186
187pub(crate) fn token_matches(expected: &str, submitted: Option<&str>) -> bool {
190 match submitted {
191 Some(t) if !expected.is_empty() => ct_eq(expected.as_bytes(), t.as_bytes()),
192 _ => false,
193 }
194}
195
196pub(crate) fn origin_ok(headers: &HeaderMap, expected_origin: &str) -> bool {
203 if let Some(site) = headers.get("sec-fetch-site").and_then(|v| v.to_str().ok()) {
204 return matches!(site, "same-origin" | "none");
205 }
206 let origin = match headers.get("origin").and_then(|v| v.to_str().ok()) {
207 Some(o) => o.trim_end_matches('/'),
208 None => return false,
209 };
210 if !expected_origin.is_empty() {
211 return origin == expected_origin.trim_end_matches('/');
212 }
213 match headers.get("host").and_then(|v| v.to_str().ok()) {
214 Some(host) => origin == format!("http://{host}") || origin == format!("https://{host}"),
215 None => false,
216 }
217}
218
219pub(crate) fn submitted_token(headers: &HeaderMap, body: &[u8]) -> Option<String> {
222 if let Some(h) = headers.get(CSRF_HEADER).and_then(|v| v.to_str().ok()) {
223 return Some(h.to_string());
224 }
225 form_urlencoded::parse(body)
226 .find(|(k, _)| k == CSRF_FIELD)
227 .map(|(_, v)| v.into_owned())
228}
229
230#[cfg(test)]
231mod tests {
232 use super::*;
233
234 #[test]
235 fn corrupt_blob_degrades_to_empty_session() {
236 assert!(SessionData::parse(Some("not json")).csrf.is_empty());
237 assert!(SessionData::parse(Some(&"x".repeat(MAX_BLOB + 1)))
238 .csrf
239 .is_empty());
240 assert!(SessionData::parse(None).flash.is_empty());
241 }
242
243 #[test]
244 fn a_fresh_session_mints_a_token_and_is_dirty() {
245 let h = SessionHandle::from_blob(None);
246 assert_eq!(h.csrf_token().len(), 64);
247 assert!(h.dirty_blob().is_some());
248 }
249
250 #[test]
251 fn an_existing_token_is_kept_and_clean() {
252 let blob = format!(r#"{{"v":1,"csrf":"{}"}}"#, "a".repeat(64));
253 let h = SessionHandle::from_blob(Some(&blob));
254 assert_eq!(h.csrf_token(), "a".repeat(64));
255 assert!(h.dirty_blob().is_none());
256 }
257
258 #[test]
259 fn flash_round_trips_and_marks_dirty() {
260 let h = SessionHandle::from_blob(Some(&format!(r#"{{"csrf":"{}"}}"#, "a".repeat(64))));
261 h.push_flash(FlashLevel::Success, Text::dynamic("Saved"));
262 assert!(h.dirty_blob().is_some());
263 let taken = h.take_flash();
264 assert_eq!(taken.len(), 1);
265 assert_eq!(taken[0].text.source(), "Saved");
266 assert!(h.take_flash().is_empty());
267 }
268
269 #[test]
270 fn token_match_is_exact_and_rejects_missing() {
271 assert!(token_matches("abc", Some("abc")));
272 assert!(!token_matches("abc", Some("abd")));
273 assert!(!token_matches("abc", None));
274 assert!(!token_matches("", Some("")));
275 }
276
277 #[test]
278 fn safe_methods_are_exempt() {
279 assert!(is_safe_method(&Method::GET));
280 assert!(is_safe_method(&Method::from_bytes(b"QUERY").unwrap()));
281 assert!(!is_safe_method(&Method::POST));
282 assert!(!is_safe_method(&Method::DELETE));
283 }
284
285 #[test]
286 fn origin_gate_accepts_same_origin_and_rejects_cross() {
287 let mut h = HeaderMap::new();
288 h.insert("sec-fetch-site", "same-origin".parse().unwrap());
289 assert!(origin_ok(&h, "https://acme.test"));
290
291 let mut h = HeaderMap::new();
292 h.insert("sec-fetch-site", "cross-site".parse().unwrap());
293 assert!(!origin_ok(&h, "https://acme.test"));
294
295 let mut h = HeaderMap::new();
296 h.insert("origin", "https://acme.test".parse().unwrap());
297 assert!(origin_ok(&h, "https://acme.test"));
298 assert!(!origin_ok(&h, "https://evil.test"));
299
300 assert!(!origin_ok(&HeaderMap::new(), "https://acme.test"));
302 }
303
304 #[test]
305 fn submitted_token_reads_header_then_field() {
306 let mut h = HeaderMap::new();
307 h.insert(CSRF_HEADER, "from-header".parse().unwrap());
308 assert_eq!(submitted_token(&h, b"").as_deref(), Some("from-header"));
309
310 let body = b"username=root&_csrf=from-field&x=1";
311 assert_eq!(
312 submitted_token(&HeaderMap::new(), body).as_deref(),
313 Some("from-field")
314 );
315 assert_eq!(submitted_token(&HeaderMap::new(), b"a=b"), None);
316 }
317}