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