atagen

malice

public
Star0
Watch0
235ce394f90da8ff124691ee4ee2997c979e7d3c
2
ssh://brewery@pls.lobotomise.me:33/atagen/malice.git

Blame: crates/malice-server/src/csrf.rs

973f8fbdaf80atagen2026-09-28 09:10 UTC1
use axum::response::Json;
973f8fbdaf80atagen2026-09-28 09:10 UTC2
use axum::{
973f8fbdaf80atagen2026-09-28 09:10 UTC3
    extract::Request,
973f8fbdaf80atagen2026-09-28 09:10 UTC4
    http::{HeaderMap, Method, StatusCode, header},
973f8fbdaf80atagen2026-09-28 09:10 UTC5
    middleware::Next,
973f8fbdaf80atagen2026-09-28 09:10 UTC6
    response::{IntoResponse, Response},
973f8fbdaf80atagen2026-09-28 09:10 UTC7
};
973f8fbdaf80atagen2026-09-28 09:10 UTC8
use serde_json::json;
973f8fbdaf80atagen2026-09-28 09:10 UTC9
973f8fbdaf80atagen2026-09-28 09:10 UTC10
pub const COOKIE_NAME: &str = "malice_session";
973f8fbdaf80atagen2026-09-28 09:10 UTC11
const COOKIE_MAX_AGE: u32 = 2_592_000;
973f8fbdaf80atagen2026-09-28 09:10 UTC12
973f8fbdaf80atagen2026-09-28 09:10 UTC13
/// Parse the opaque session id from the `Cookie` header.
973f8fbdaf80atagen2026-09-28 09:10 UTC14
///
973f8fbdaf80atagen2026-09-28 09:10 UTC15
/// Returns `None` when the cookie is absent or malformed. The value is
973f8fbdaf80atagen2026-09-28 09:10 UTC16
/// restricted to a safe alphabet so it can be used as a file name.
973f8fbdaf80atagen2026-09-28 09:10 UTC17
pub fn session_sid(headers: &HeaderMap) -> Option<String> {
973f8fbdaf80atagen2026-09-28 09:10 UTC18
    for value in headers.get_all(header::COOKIE) {
973f8fbdaf80atagen2026-09-28 09:10 UTC19
        let Ok(raw) = value.to_str() else {
973f8fbdaf80atagen2026-09-28 09:10 UTC20
            continue;
973f8fbdaf80atagen2026-09-28 09:10 UTC21
        };
973f8fbdaf80atagen2026-09-28 09:10 UTC22
        for part in raw.split(';') {
973f8fbdaf80atagen2026-09-28 09:10 UTC23
            let part = part.trim();
973f8fbdaf80atagen2026-09-28 09:10 UTC24
            let Some((name, val)) = part.split_once('=') else {
973f8fbdaf80atagen2026-09-28 09:10 UTC25
                continue;
973f8fbdaf80atagen2026-09-28 09:10 UTC26
            };
973f8fbdaf80atagen2026-09-28 09:10 UTC27
            if name.trim() != COOKIE_NAME {
973f8fbdaf80atagen2026-09-28 09:10 UTC28
                continue;
973f8fbdaf80atagen2026-09-28 09:10 UTC29
            }
973f8fbdaf80atagen2026-09-28 09:10 UTC30
            let val = val.trim().trim_matches('"');
973f8fbdaf80atagen2026-09-28 09:10 UTC31
            if valid_sid(val) {
973f8fbdaf80atagen2026-09-28 09:10 UTC32
                return Some(val.to_owned());
973f8fbdaf80atagen2026-09-28 09:10 UTC33
            }
973f8fbdaf80atagen2026-09-28 09:10 UTC34
        }
973f8fbdaf80atagen2026-09-28 09:10 UTC35
    }
973f8fbdaf80atagen2026-09-28 09:10 UTC36
    None
973f8fbdaf80atagen2026-09-28 09:10 UTC37
}
973f8fbdaf80atagen2026-09-28 09:10 UTC38
973f8fbdaf80atagen2026-09-28 09:10 UTC39
pub fn valid_sid(value: &str) -> bool {
973f8fbdaf80atagen2026-09-28 09:10 UTC40
    !value.is_empty()
973f8fbdaf80atagen2026-09-28 09:10 UTC41
        && value.len() <= 128
973f8fbdaf80atagen2026-09-28 09:10 UTC42
        && value
973f8fbdaf80atagen2026-09-28 09:10 UTC43
            .chars()
973f8fbdaf80atagen2026-09-28 09:10 UTC44
            .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
973f8fbdaf80atagen2026-09-28 09:10 UTC45
}
973f8fbdaf80atagen2026-09-28 09:10 UTC46
973f8fbdaf80atagen2026-09-28 09:10 UTC47
pub fn set_cookie_value(sid: &str, secure: bool) -> String {
973f8fbdaf80atagen2026-09-28 09:10 UTC48
    let mut cookie =
973f8fbdaf80atagen2026-09-28 09:10 UTC49
        format!("{COOKIE_NAME}={sid}; Path=/; HttpOnly; SameSite=Lax; Max-Age={COOKIE_MAX_AGE}");
973f8fbdaf80atagen2026-09-28 09:10 UTC50
    if secure {
973f8fbdaf80atagen2026-09-28 09:10 UTC51
        cookie.push_str("; Secure");
973f8fbdaf80atagen2026-09-28 09:10 UTC52
    }
973f8fbdaf80atagen2026-09-28 09:10 UTC53
    cookie
973f8fbdaf80atagen2026-09-28 09:10 UTC54
}
973f8fbdaf80atagen2026-09-28 09:10 UTC55
973f8fbdaf80atagen2026-09-28 09:10 UTC56
pub fn clear_cookie_value(secure: bool) -> String {
973f8fbdaf80atagen2026-09-28 09:10 UTC57
    let mut cookie = format!("{COOKIE_NAME}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0");
973f8fbdaf80atagen2026-09-28 09:10 UTC58
    if secure {
973f8fbdaf80atagen2026-09-28 09:10 UTC59
        cookie.push_str("; Secure");
973f8fbdaf80atagen2026-09-28 09:10 UTC60
    }
973f8fbdaf80atagen2026-09-28 09:10 UTC61
    cookie
973f8fbdaf80atagen2026-09-28 09:10 UTC62
}
973f8fbdaf80atagen2026-09-28 09:10 UTC63
973f8fbdaf80atagen2026-09-28 09:10 UTC64
/// Reject cross-origin mutating API requests. Port is part of an origin:
973f8fbdaf80atagen2026-09-28 09:10 UTC65
/// another app on the same host must not be able to drive this user's cookie.
973f8fbdaf80atagen2026-09-28 09:10 UTC66
/// The dev proxy preserves Host, so its forwarded requests remain same-origin.
973f8fbdaf80atagen2026-09-28 09:10 UTC67
pub async fn csrf_guard(request: Request, next: Next) -> Response {
973f8fbdaf80atagen2026-09-28 09:10 UTC68
    let method = request.method().clone();
973f8fbdaf80atagen2026-09-28 09:10 UTC69
    let path = request.uri().path().to_owned();
973f8fbdaf80atagen2026-09-28 09:10 UTC70
    let is_api = path == "/api" || path.starts_with("/api/");
973f8fbdaf80atagen2026-09-28 09:10 UTC71
    let is_mutating = method != Method::GET && method != Method::HEAD && method != Method::OPTIONS;
973f8fbdaf80atagen2026-09-28 09:10 UTC72
    if !(is_api && is_mutating) {
973f8fbdaf80atagen2026-09-28 09:10 UTC73
        return next.run(request).await;
973f8fbdaf80atagen2026-09-28 09:10 UTC74
    }
973f8fbdaf80atagen2026-09-28 09:10 UTC75
    let headers = request.headers();
973f8fbdaf80atagen2026-09-28 09:10 UTC76
    // Host only: X-Forwarded-Host is client-controlled and must not decide
973f8fbdaf80atagen2026-09-28 09:10 UTC77
    // same-origin. The Host the backend received is what the browser used,
973f8fbdaf80atagen2026-09-28 09:10 UTC78
    // which is exactly what Origin must match.
973f8fbdaf80atagen2026-09-28 09:10 UTC79
    let host = headers
973f8fbdaf80atagen2026-09-28 09:10 UTC80
        .get(header::HOST)
973f8fbdaf80atagen2026-09-28 09:10 UTC81
        .and_then(|value| value.to_str().ok())
973f8fbdaf80atagen2026-09-28 09:10 UTC82
        .unwrap_or("");
973f8fbdaf80atagen2026-09-28 09:10 UTC83
    let origin = headers
973f8fbdaf80atagen2026-09-28 09:10 UTC84
        .get(header::ORIGIN)
973f8fbdaf80atagen2026-09-28 09:10 UTC85
        .and_then(|value| value.to_str().ok());
973f8fbdaf80atagen2026-09-28 09:10 UTC86
    let referer = headers
973f8fbdaf80atagen2026-09-28 09:10 UTC87
        .get(header::REFERER)
973f8fbdaf80atagen2026-09-28 09:10 UTC88
        .and_then(|value| value.to_str().ok());
973f8fbdaf80atagen2026-09-28 09:10 UTC89
    let allowed = match (origin, referer) {
973f8fbdaf80atagen2026-09-28 09:10 UTC90
        (Some(value), _) => same_authority(value, host),
973f8fbdaf80atagen2026-09-28 09:10 UTC91
        (None, Some(value)) => same_authority(value, host),
973f8fbdaf80atagen2026-09-28 09:10 UTC92
        (None, None) => true,
973f8fbdaf80atagen2026-09-28 09:10 UTC93
    };
973f8fbdaf80atagen2026-09-28 09:10 UTC94
    if !allowed {
973f8fbdaf80atagen2026-09-28 09:10 UTC95
        return (
973f8fbdaf80atagen2026-09-28 09:10 UTC96
            StatusCode::FORBIDDEN,
973f8fbdaf80atagen2026-09-28 09:10 UTC97
            Json(json!({"message": "origin check failed"})),
973f8fbdaf80atagen2026-09-28 09:10 UTC98
        )
973f8fbdaf80atagen2026-09-28 09:10 UTC99
            .into_response();
973f8fbdaf80atagen2026-09-28 09:10 UTC100
    }
973f8fbdaf80atagen2026-09-28 09:10 UTC101
    next.run(request).await
973f8fbdaf80atagen2026-09-28 09:10 UTC102
}
973f8fbdaf80atagen2026-09-28 09:10 UTC103
973f8fbdaf80atagen2026-09-28 09:10 UTC104
fn same_authority(source: &str, host: &str) -> bool {
973f8fbdaf80atagen2026-09-28 09:10 UTC105
    source.parse::<axum::http::Uri>().ok().is_some_and(|uri| {
973f8fbdaf80atagen2026-09-28 09:10 UTC106
        matches!(uri.scheme_str(), Some("http" | "https"))
973f8fbdaf80atagen2026-09-28 09:10 UTC107
            && uri
973f8fbdaf80atagen2026-09-28 09:10 UTC108
                .authority()
973f8fbdaf80atagen2026-09-28 09:10 UTC109
                .is_some_and(|authority| authority.as_str().eq_ignore_ascii_case(host))
973f8fbdaf80atagen2026-09-28 09:10 UTC110
    })
973f8fbdaf80atagen2026-09-28 09:10 UTC111
}