973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 1 | use axum::response::Json; |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 2 | use axum::{ |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 3 | extract::Request, |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 4 | http::{HeaderMap, Method, StatusCode, header}, |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 5 | middleware::Next, |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 6 | response::{IntoResponse, Response}, |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 7 | }; |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 8 | use serde_json::json; |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 9 | |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 10 | pub const COOKIE_NAME: &str = "malice_session"; |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 11 | const COOKIE_MAX_AGE: u32 = 2_592_000; |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 12 | |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 13 | /// Parse the opaque session id from the `Cookie` header. |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 14 | /// |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 15 | /// Returns `None` when the cookie is absent or malformed. The value is |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 16 | /// restricted to a safe alphabet so it can be used as a file name. |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 17 | pub fn session_sid(headers: &HeaderMap) -> Option<String> { |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 18 | for value in headers.get_all(header::COOKIE) { |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 19 | let Ok(raw) = value.to_str() else { |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 20 | continue; |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 21 | }; |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 22 | for part in raw.split(';') { |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 23 | let part = part.trim(); |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 24 | let Some((name, val)) = part.split_once('=') else { |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 25 | continue; |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 26 | }; |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 27 | if name.trim() != COOKIE_NAME { |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 28 | continue; |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 29 | } |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 30 | let val = val.trim().trim_matches('"'); |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 31 | if valid_sid(val) { |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 32 | return Some(val.to_owned()); |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 33 | } |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 34 | } |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 35 | } |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 36 | None |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 37 | } |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 38 | |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 39 | pub fn valid_sid(value: &str) -> bool { |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 40 | !value.is_empty() |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 41 | && value.len() <= 128 |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 42 | && value |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 43 | .chars() |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 44 | .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 45 | } |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 46 | |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 47 | pub fn set_cookie_value(sid: &str, secure: bool) -> String { |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 48 | let mut cookie = |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 49 | format!("{COOKIE_NAME}={sid}; Path=/; HttpOnly; SameSite=Lax; Max-Age={COOKIE_MAX_AGE}"); |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 50 | if secure { |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 51 | cookie.push_str("; Secure"); |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 52 | } |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 53 | cookie |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 54 | } |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 55 | |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 56 | pub fn clear_cookie_value(secure: bool) -> String { |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 57 | let mut cookie = format!("{COOKIE_NAME}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0"); |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 58 | if secure { |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 59 | cookie.push_str("; Secure"); |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 60 | } |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 61 | cookie |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 62 | } |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 63 | |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 64 | /// Reject cross-origin mutating API requests. Port is part of an origin: |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 65 | /// another app on the same host must not be able to drive this user's cookie. |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 66 | /// The dev proxy preserves Host, so its forwarded requests remain same-origin. |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 67 | pub async fn csrf_guard(request: Request, next: Next) -> Response { |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 68 | let method = request.method().clone(); |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 69 | let path = request.uri().path().to_owned(); |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 70 | let is_api = path == "/api" || path.starts_with("/api/"); |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 71 | let is_mutating = method != Method::GET && method != Method::HEAD && method != Method::OPTIONS; |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 72 | if !(is_api && is_mutating) { |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 73 | return next.run(request).await; |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 74 | } |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 75 | let headers = request.headers(); |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 76 | // Host only: X-Forwarded-Host is client-controlled and must not decide |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 77 | // same-origin. The Host the backend received is what the browser used, |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 78 | // which is exactly what Origin must match. |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 79 | let host = headers |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 80 | .get(header::HOST) |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 81 | .and_then(|value| value.to_str().ok()) |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 82 | .unwrap_or(""); |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 83 | let origin = headers |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 84 | .get(header::ORIGIN) |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 85 | .and_then(|value| value.to_str().ok()); |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 86 | let referer = headers |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 87 | .get(header::REFERER) |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 88 | .and_then(|value| value.to_str().ok()); |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 89 | let allowed = match (origin, referer) { |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 90 | (Some(value), _) => same_authority(value, host), |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 91 | (None, Some(value)) => same_authority(value, host), |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 92 | (None, None) => true, |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 93 | }; |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 94 | if !allowed { |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 95 | return ( |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 96 | StatusCode::FORBIDDEN, |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 97 | Json(json!({"message": "origin check failed"})), |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 98 | ) |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 99 | .into_response(); |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 100 | } |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 101 | next.run(request).await |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 102 | } |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 103 | |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 104 | fn same_authority(source: &str, host: &str) -> bool { |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 105 | source.parse::<axum::http::Uri>().ok().is_some_and(|uri| { |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 106 | matches!(uri.scheme_str(), Some("http" | "https")) |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 107 | && uri |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 108 | .authority() |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 109 | .is_some_and(|authority| authority.as_str().eq_ignore_ascii_case(host)) |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 110 | }) |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 111 | } |