AkurAI Build
Menu

Public repository / main

AkurAI-CRM / crates/crm-api/src/auth/csrf.rs

Latest change 3ca078e63808e39f59571f8562e9a829f261ae13 - Restore CSRF guard, admin-gated merge, IdP logout and login redirects by Ólafur Búi Ólafsson

//! Cross-site request forgery guard for cookie-authenticated requests.

use akurai_http::{Method, Request, Response};

pub const CROSS_ORIGIN: &str = "state-changing requests must originate from AkurAI-CRM";

/// `scheme://host[:port]` of a URL.
fn origin_of(url: &str) -> Option<&str> {
    let scheme_end = url.find("://")? + 3;
    let end = url[scheme_end..]
        .find(['/', '?', '#'])
        .map_or(url.len(), |i| scheme_end + i);
    (end > scheme_end).then(|| &url[..end])
}

/// Unsafe-method requests without a bearer credential must carry an
/// `Origin` (or, failing that, `Referer`) from the CRM's public origin.
/// `/mcp` does its own origin check.
pub fn check(req: &Request, public_url: &str) -> Result<(), Response> {
    let safe = matches!(req.method, Method::Get | Method::Head | Method::Options);
    let bearer = req
        .header("Authorization")
        .is_some_and(|value| value.starts_with("Bearer "));
    if safe || bearer || req.path == "/mcp" {
        return Ok(());
    }
    let expected = origin_of(public_url).unwrap_or(public_url);
    let presented = req
        .header("Origin")
        .or_else(|| req.header("Referer"))
        .and_then(origin_of);
    if presented.is_some_and(|origin| origin.eq_ignore_ascii_case(expected)) {
        Ok(())
    } else {
        Err(Response::new(403).with_text(CROSS_ORIGIN))
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    const PUBLIC: &str = "https://crm.test";

    fn req(method: &str, path: &str, headers: &str) -> Request {
        Request::parse_head(format!("{method} {path} HTTP/1.1\r\n{headers}\r\n").as_bytes())
            .unwrap()
    }

    fn allowed(method: &str, headers: &str) -> bool {
        check(&req(method, "/api/people", headers), PUBLIC).is_ok()
    }

    #[test]
    fn safe_methods_pass() {
        assert!(allowed("GET", "Origin: https://evil.test\r\n"));
        assert!(allowed("OPTIONS", ""));
    }

    #[test]
    fn same_origin_passes_by_origin_or_referer() {
        assert!(allowed("POST", "Origin: https://crm.test\r\n"));
        assert!(allowed(
            "DELETE",
            "Referer: https://crm.test/people.html?x=1\r\n"
        ));
    }

    #[test]
    fn cross_or_missing_origin_is_rejected() {
        assert!(!allowed("POST", "Origin: https://evil.test\r\n"));
        assert!(!allowed("PUT", "Origin: https://crm.test.evil.test\r\n"));
        assert!(!allowed(
            "POST",
            "Referer: https://evil.test/https://crm.test\r\n"
        ));
        assert!(!allowed("POST", ""));
        let error = check(&req("POST", "/api/people", ""), PUBLIC).unwrap_err();
        assert_eq!(error.status, 403);
        assert_eq!(error.body, CROSS_ORIGIN.as_bytes());
    }

    #[test]
    fn bearer_and_mcp_requests_are_unaffected() {
        assert!(allowed("POST", "Authorization: Bearer t\r\n"));
        assert!(check(
            &req("POST", "/mcp", "Origin: https://evil.test\r\n"),
            PUBLIC
        )
        .is_ok());
    }
}