atagen

malice

public
Star0
Watch0
b9b85b76355e2e8d0874d95d161058b25e5d3111
2
ssh://brewery@pls.lobotomise.me:33/atagen/malice.git
crates/malice-server/src/csrf.rsRust111 lines3.6 KB
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
}