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());
}
}