feat(auth): CSRF defense-in-depth middleware

The primary auth path uses `Authorization: Bearer <jwt>` — a scheme
the browser never auto-attaches — so classical CSRF against a malicious
same-origin form POST is already neutralized. This middleware adds a
belt-and-suspenders layer: state-changing requests must carry an
`X-CSRF-Token` header, which cross-origin attackers cannot set on
simple requests (the browser's CORS preflight blocks them).

- New `core/auth/csrf.rs` with `CsrfMiddleware` / `CsrfMiddlewareService`
  that mirror the `AuthMiddleware` shape (Actix `Transform` + `Service`,
  `EitherBody<B>` response). Missing header on a state-changing route
  yields 403 with a JSON body naming the required header.
- Decision policy extracted as `should_require_csrf_token(path, method,
  has_api_key)` — a pure function so unit tests cover every rule without
  an HTTP harness.
- Exempt:
  - `GET` / `HEAD` / `OPTIONS` (safe methods).
  - `/api/auth/login` and `/api/setup/*` (no session yet, so no token
    to match).
  - `/ws/*` (WebSocket upgrade; no body to forge).
  - Any request presenting `X-API-Key` (sealed credential; the request
    is not a browser navigation).
- Wired into the main `/api` scope in `http_server.rs` inside the
  existing `AuthMiddleware`, so JWT validation runs first and CSRF
  runs closer to the handler.

Tests: 6 new (GET skips, state-changing admin routes require token,
login / setup / WS exempt, API-key exemption takes precedence over
path rules). 230 pass total. clippy --package net-guardia -- -D
warnings clean.

Closes I-region I-11.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
DaLaw2 2026-04-18 17:31:16 +08:00
parent 004daafdcf
commit 1578ff2071
3 changed files with 206 additions and 0 deletions

View File

@ -0,0 +1,203 @@
//! CSRF defense-in-depth middleware.
//!
//! The primary auth path uses `Authorization: Bearer <jwt>` — a scheme
//! the browser never auto-attaches — so classical CSRF against a
//! malicious same-origin form POST is already neutralized. This
//! middleware adds a belt-and-suspenders layer on top:
//!
//! - State-changing requests (anything that isn't `GET`/`HEAD`/`OPTIONS`)
//! must carry an `X-CSRF-Token` header.
//! - The header's presence alone is the check. Cross-origin attackers
//! cannot set custom request headers on simple requests (browsers
//! block that via the CORS preflight), so a successful request from
//! a third-party page would need to run JS inside our origin, at
//! which point CSRF is the wrong threat label anyway.
//! - Exempt: auth / setup bootstrap endpoints (no session yet),
//! WebSocket upgrade (no body to forge), and `X-API-Key`
//! authentication (sealed credential — the request isn't a browser
//! navigation at all).
//!
//! The decision lives in `should_require_csrf_token` so unit tests can
//! cover the path without spinning up an Actix test harness.
use std::future::{Future, Ready, ready};
use std::pin::Pin;
use std::rc::Rc;
use std::task::{Context, Poll};
use actix_web::body::EitherBody;
use actix_web::dev::{Service, ServiceRequest, ServiceResponse, Transform};
use actix_web::http::Method;
use actix_web::http::header::HeaderMap;
use actix_web::{Error as ActixError, HttpResponse};
/// Request header carrying the CSRF token. Clients (frontend fetch /
/// axios wrappers) set this on every state-changing request; its value
/// is whatever the client produced (we don't validate content).
pub const CSRF_HEADER: &str = "X-CSRF-Token";
/// Header used by non-browser clients for API-key authentication. Such
/// clients are exempt from the CSRF requirement.
const API_KEY_HEADER: &str = "X-API-Key";
pub struct CsrfMiddleware;
impl<S, B> Transform<S, ServiceRequest> for CsrfMiddleware
where
S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
B: 'static,
{
type Response = ServiceResponse<EitherBody<B>>;
type Error = ActixError;
type Transform = CsrfMiddlewareService<S>;
type InitError = ();
type Future = Ready<Result<Self::Transform, Self::InitError>>;
fn new_transform(&self, service: S) -> Self::Future {
ready(Ok(CsrfMiddlewareService {
service: Rc::new(service),
}))
}
}
pub struct CsrfMiddlewareService<S> {
service: Rc<S>,
}
impl<S, B> Service<ServiceRequest> for CsrfMiddlewareService<S>
where
S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
B: 'static,
{
type Response = ServiceResponse<EitherBody<B>>;
type Error = ActixError;
type Future = Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>>>>;
fn poll_ready(&self, ctx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
self.service.poll_ready(ctx)
}
fn call(&self, req: ServiceRequest) -> Self::Future {
let service = Rc::clone(&self.service);
let path = req.path().to_string();
let method = req.method().clone();
let has_api_key = req.headers().contains_key(API_KEY_HEADER);
let has_csrf_token = req.headers().contains_key(CSRF_HEADER);
Box::pin(async move {
if should_require_csrf_token(&path, &method, has_api_key) && !has_csrf_token {
let resp = HttpResponse::Forbidden().json(serde_json::json!({
"error": "Missing CSRF token",
"header": CSRF_HEADER,
}));
return Ok(req.into_response(resp).map_into_right_body());
}
let res = service.call(req).await?.map_into_left_body();
Ok(res)
})
}
}
/// Decide whether a request must present a CSRF token. The rules are
/// extracted as a free function so the middleware is a thin shim and
/// the policy can be unit-tested without an HTTP harness.
pub fn should_require_csrf_token(path: &str, method: &Method, has_api_key: bool) -> bool {
if has_api_key {
return false;
}
if !is_state_changing(method) {
return false;
}
if is_csrf_exempt_path(path) {
return false;
}
true
}
fn is_state_changing(method: &Method) -> bool {
!matches!(*method, Method::GET | Method::HEAD | Method::OPTIONS)
}
/// Paths that cannot meaningfully carry a CSRF token because the
/// session that would mint one hasn't been established yet, or because
/// the route uses a protocol outside the CSRF threat model.
fn is_csrf_exempt_path(path: &str) -> bool {
// Login / setup bootstrap: no session yet, so no token to match.
if path == "/api/auth/login" || path.starts_with("/api/setup/") {
return true;
}
// WebSocket upgrade happens over a GET anyway, but list the prefix
// explicitly so the intent is visible when someone reads the file.
if path.starts_with("/ws/") {
return true;
}
false
}
/// Convenience accessor used by request-scoped code that would
/// otherwise reach into `req.headers().get(CSRF_HEADER)` directly.
#[allow(dead_code)]
pub fn csrf_token_from_headers(headers: &HeaderMap) -> Option<&str> {
headers.get(CSRF_HEADER).and_then(|v| v.to_str().ok())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn get_requests_do_not_need_csrf_token() {
assert!(!should_require_csrf_token("/api/acl/rules", &Method::GET, false));
assert!(!should_require_csrf_token("/api/stats/summary", &Method::HEAD, false));
assert!(!should_require_csrf_token("/api/ml/status", &Method::OPTIONS, false));
}
#[test]
fn state_changing_requests_on_admin_routes_require_csrf_token() {
assert!(should_require_csrf_token("/api/acl/rules", &Method::POST, false));
assert!(should_require_csrf_token(
"/api/ml/models/current",
&Method::DELETE,
false
));
assert!(should_require_csrf_token("/api/soar/playbooks/1", &Method::PUT, false));
assert!(should_require_csrf_token(
"/api/notifications/telegram",
&Method::PATCH,
false
));
}
#[test]
fn login_and_setup_are_exempt() {
// Login hasn't yet issued a session, so there's no token to carry.
assert!(!should_require_csrf_token("/api/auth/login", &Method::POST, false));
assert!(!should_require_csrf_token(
"/api/setup/initialize",
&Method::POST,
false
));
}
#[test]
fn websocket_upgrade_is_exempt() {
// WS upgrade is a GET anyway but stays exempt under any verb.
assert!(!should_require_csrf_token("/ws/events", &Method::GET, false));
assert!(!should_require_csrf_token("/ws/events", &Method::POST, false));
}
#[test]
fn api_key_clients_are_exempt_even_on_state_changing_routes() {
// Non-browser clients present a sealed credential; CSRF is a
// browser threat model.
assert!(!should_require_csrf_token("/api/acl/rules", &Method::POST, true));
assert!(!should_require_csrf_token("/api/soar/playbooks", &Method::DELETE, true));
}
#[test]
fn api_key_exemption_takes_precedence_over_path_rules() {
// Even if the path is a state-changing admin route, the API-key
// header flips the requirement off before the path check runs.
assert!(!should_require_csrf_token("/api/system/reload", &Method::POST, true));
}
}

View File

@ -1,3 +1,4 @@
pub mod csrf;
pub mod extractor;
pub mod https_redirect;
pub mod jwt;

View File

@ -17,6 +17,7 @@ use crate::adapter::http::{
use crate::adapter::persistence::Database;
use crate::adapter::websocket::routes as ws;
use crate::core::acl_service::AclService;
use crate::core::auth::csrf::CsrfMiddleware;
use crate::core::auth::https_redirect::{ForceHttpsFlag, HttpsRedirect};
use crate::core::auth::jwt::JwtService;
use crate::core::auth::middleware::AuthMiddleware;
@ -271,6 +272,7 @@ pub async fn run(params: HttpServerParams) -> Result<(), Error> {
app.wrap(SetupGuard)
.service(
web::scope("/api")
.wrap(CsrfMiddleware)
.wrap(AuthMiddleware)
.service(auth::initialize())
.service(acl::initialize())