csrf.rs 642 lignes · 22475 octets
//! CSRF Protection Middleware
//!
//! Protects against Cross-Site Request Forgery (CSRF) attacks via
//! session-bound tokens for state-changing operations.
//!
//! Two entry points:
//! - `csrf_protection` — default behavior. Accepts the token only in the
//!   `X-CSRF-Token` header. Suited to JS clients / HTMX.
//! - `csrf_protection_with_config` — explicit `CsrfProtectionConfig`.
//!   When `accept_form_body = true`, also looks for `csrf_token=...` in a
//!   `application/x-www-form-urlencoded` body and buffers it up to
//!   `max_form_body_bytes`. Suited to SSR apps without JS (e.g. Gitrust).
//!
//! Security properties (inherited):
//! - SEC-H4: constant-time token comparison.
//! - SEC-C3: blocks state-changing requests without a token, except those
//!   carrying an `Authorization: Bearer ...` header (API clients, not
//!   browser-driven, so not CSRF-vulnerable).

use axum::{
    body::Body,
    extract::{Request, State},
    http::{Method, StatusCode, Uri, header::CONTENT_TYPE},
    middleware::Next,
    response::{IntoResponse, Response},
};
use rand::RngCore;
use sea_orm::DatabaseConnection;
use std::sync::Arc;
use subtle::ConstantTimeEq;
use tower_sessions::Session;

use crate::error::AppError;

/// Default buffer cap for form bodies that may carry a CSRF token: 1 MiB.
pub const DEFAULT_MAX_FORM_BODY_BYTES: usize = 1024 * 1024;

/// Runtime configuration for [`csrf_protection_with_config`].
///
/// The defaults match the behavior of [`csrf_protection`] (header-only, no
/// body parsing), so an app that opts in to the configurable wrapper without
/// changing any field sees no behavior change.
#[derive(Debug, Clone)]
pub struct CsrfProtectionConfig {
    /// When true, if the `X-CSRF-Token` header is absent and the request
    /// declares `Content-Type: application/x-www-form-urlencoded`, the body
    /// is buffered and scanned for a `csrf_token` field.
    ///
    /// Multipart bodies are never buffered (too risky on large uploads).
    pub accept_form_body: bool,

    /// Maximum number of bytes buffered when `accept_form_body` is true.
    /// Requests that exceed this cap are rejected with HTTP 413.
    pub max_form_body_bytes: usize,
}

impl Default for CsrfProtectionConfig {
    fn default() -> Self {
        Self {
            accept_form_body: false,
            max_form_body_bytes: DEFAULT_MAX_FORM_BODY_BYTES,
        }
    }
}

/// Session key for CSRF token
const CSRF_TOKEN_KEY: &str = "csrf_token";

/// Generate a random CSRF token
fn generate_csrf_token() -> String {
    let mut rng = rand::rng();
    let mut bytes = [0u8; 32];
    rng.fill_bytes(&mut bytes);
    hex::encode(bytes)
}

/// Get or create CSRF token from session
///
/// # Arguments
///
/// * `session` - Tower session
///
/// # Returns
///
/// CSRF token string
pub async fn get_or_create_csrf_token(session: &Session) -> Result<String, AppError> {
    // Try to get existing token from session
    match session.get::<String>(CSRF_TOKEN_KEY).await {
        Ok(Some(token)) => return Ok(token),
        Ok(None) => {
            // Token doesn't exist, create one
        }
        Err(e) => {
            tracing::warn!("Failed to get CSRF token from session: {}", e);
            // Continue to create new token
        }
    }

    // Generate new token if not exists
    let token = generate_csrf_token();
    session.insert(CSRF_TOKEN_KEY, &token).await.map_err(|e| {
        tracing::error!("Failed to store CSRF token in session: {}", e);
        AppError::Internal(format!("Failed to store CSRF token: {}", e))
    })?;
    Ok(token)
}

/// Extract the CSRF token from the `X-CSRF-Token` header only.
///
/// Body-based extraction is handled separately by [`extract_csrf_from_form`]
/// when the middleware is configured with `accept_form_body = true`.
fn extract_header_token(request: &Request) -> Option<String> {
    request
        .headers()
        .get("X-CSRF-Token")
        .and_then(|v| v.to_str().ok())
        .map(|s| s.to_string())
}

/// True iff the request declares `Content-Type: application/x-www-form-urlencoded`.
fn is_form_urlencoded(request: &Request) -> bool {
    request
        .headers()
        .get(CONTENT_TYPE)
        .and_then(|v| v.to_str().ok())
        .map(|v| {
            v.to_ascii_lowercase()
                .starts_with("application/x-www-form-urlencoded")
        })
        .unwrap_or(false)
}

/// Extract the `csrf_token` field from a URL-encoded form body.
///
/// Returns `None` when:
/// - the body is not valid UTF-8,
/// - no `csrf_token` field is present,
/// - the URL-decoded value is invalid.
///
/// Exposed as `pub` so apps that run their own buffering can still reuse the
/// framework's parser (matches proposition B in
/// [`docs/UPSTREAM_RUSTWARDEN.md`](../../../../docs/UPSTREAM_RUSTWARDEN.md)).
pub fn extract_csrf_from_form(body: &[u8]) -> Option<String> {
    let text = std::str::from_utf8(body).ok()?;
    text.split('&')
        .filter_map(|pair| pair.split_once('='))
        .find(|(k, _)| *k == "csrf_token")
        .and_then(|(_, v)| urlencoding::decode(v).ok())
        .map(|cow| cow.into_owned())
}

/// Middleware that validates CSRF tokens for state-changing operations
///
/// This middleware:
/// - Allows GET/HEAD/OPTIONS requests without CSRF token
/// - Requires valid CSRF token for POST/PUT/DELETE/PATCH requests
/// - Validates token against session-stored token
///
/// # Arguments
///
/// * `State(db)` - Database connection
/// * `session` - Tower session
/// * `uri` - Request URI
/// * `request` - HTTP request
/// * `next` - Next middleware or handler
///
/// # Returns
///
/// * `200 OK` if valid or safe method (passes to handler)
/// * `403 Forbidden` if CSRF token missing or invalid
///
/// SEC-C3: Check if the request uses Bearer authentication (API client)
/// API clients using Authorization: Bearer are not vulnerable to CSRF
/// because the token is not sent automatically by the browser.
fn is_bearer_api_request(request: &Request) -> bool {
    request
        .headers()
        .get(axum::http::header::AUTHORIZATION)
        .and_then(|v| v.to_str().ok())
        .map(|v| v.to_lowercase().starts_with("bearer "))
        .unwrap_or(false)
}

/// SEC-H4: Constant-time comparison of CSRF tokens
fn csrf_tokens_match(a: &str, b: &str) -> bool {
    a.as_bytes().ct_eq(b.as_bytes()).into()
}

/// Outcome of running the CSRF check against an incoming request.
///
/// `Pass` carries the (possibly reconstructed) request forward so body-buffered
/// flows can replay the consumed bytes to downstream extractors.
#[derive(Debug)]
enum CsrfOutcome {
    /// Request is safe, Bearer-authed, or token validated — forward to `next`.
    Pass(Request),
    /// Token missing, mismatched, or body cap exceeded — short-circuit.
    Reject(Response),
}

/// Core CSRF check with configurable body buffering.
///
/// Flow:
/// 1. Safe method (`GET`/`HEAD`/`OPTIONS`) → pass.
/// 2. `Authorization: Bearer ...` → pass (API client, not CSRF-vulnerable).
/// 3. `X-CSRF-Token` header present → validate and decide.
/// 4. Else if `cfg.accept_form_body` and `Content-Type: application/x-www-form-urlencoded` →
///    buffer (capped at `cfg.max_form_body_bytes`), scan for `csrf_token`, validate.
///    The buffered body is reattached to the request so the downstream handler
///    can still decode its `Form<T>`.
/// 5. Else → reject (403 CSRF token required).
async fn csrf_check(
    session: &Session,
    uri: &Uri,
    request: Request,
    cfg: &CsrfProtectionConfig,
) -> Result<CsrfOutcome, AppError> {
    let method = request.method().clone();

    // SEC-H4: TRACE removed — only safe idempotent methods are exempt.
    if matches!(method, Method::GET | Method::HEAD | Method::OPTIONS) {
        return Ok(CsrfOutcome::Pass(request));
    }

    // SEC-C3: Skip CSRF for Bearer API clients.
    if is_bearer_api_request(&request) {
        tracing::debug!("CSRF skip: Bearer API request for {} {}", method, uri);
        return Ok(CsrfOutcome::Pass(request));
    }

    tracing::debug!("CSRF protection: validating token for {} {}", method, uri);
    let session_token = get_or_create_csrf_token(session).await?;

    // Prefer the header: cheap and non-destructive.
    if let Some(token) = extract_header_token(&request) {
        return Ok(validate_or_reject(
            &session_token,
            &token,
            &method,
            uri,
            request,
        ));
    }

    // Fall back to the form body when explicitly enabled.
    if cfg.accept_form_body && is_form_urlencoded(&request) {
        let (parts, body) = request.into_parts();
        let bytes = match axum::body::to_bytes(body, cfg.max_form_body_bytes).await {
            Ok(b) => b,
            Err(err) => {
                tracing::warn!(
                    cap = cfg.max_form_body_bytes,
                    error = %err,
                    "CSRF body buffering failed (cap exceeded or stream error) for {} {}",
                    method,
                    uri,
                );
                return Ok(CsrfOutcome::Reject(
                    (
                        StatusCode::PAYLOAD_TOO_LARGE,
                        "Form body too large for CSRF check",
                    )
                        .into_response(),
                ));
            }
        };
        let body_token = extract_csrf_from_form(&bytes);
        // Reconstruct the request *before* deciding, so that a valid token
        // still has a body available for the downstream handler.
        let reconstructed = Request::from_parts(parts, Body::from(bytes));
        return Ok(match body_token {
            Some(t) => validate_or_reject(&session_token, &t, &method, uri, reconstructed),
            None => {
                tracing::warn!("CSRF token missing from form body for {} {}", method, uri);
                CsrfOutcome::Reject((StatusCode::FORBIDDEN, "CSRF token required").into_response())
            }
        });
    }

    // SEC-C3: No token at all → block.
    tracing::warn!("CSRF token missing for {} {}", method, uri);
    Ok(CsrfOutcome::Reject(
        (StatusCode::FORBIDDEN, "CSRF token required").into_response(),
    ))
}

fn validate_or_reject(
    session_token: &str,
    incoming: &str,
    method: &Method,
    uri: &Uri,
    request: Request,
) -> CsrfOutcome {
    if csrf_tokens_match(incoming, session_token) {
        tracing::debug!("CSRF token validated for {} {}", method, uri);
        CsrfOutcome::Pass(request)
    } else {
        tracing::warn!(
            "CSRF token mismatch for {} {} (expected: {}..., got: {}...)",
            method,
            uri,
            &session_token[..8.min(session_token.len())],
            &incoming[..8.min(incoming.len())]
        );
        CsrfOutcome::Reject((StatusCode::FORBIDDEN, "Invalid CSRF token").into_response())
    }
}

/// Default header-only CSRF middleware.
///
/// Wire as `axum::middleware::from_fn_with_state(db, csrf_protection)` on a
/// router whose routes already reject without a token. For SSR apps that only
/// have the token in a form body, use [`csrf_protection_with_config`] instead.
pub async fn csrf_protection(
    State(_db): State<DatabaseConnection>,
    session: Session,
    uri: Uri,
    request: Request,
    next: Next,
) -> Result<Response, AppError> {
    let cfg = CsrfProtectionConfig::default();
    match csrf_check(&session, &uri, request, &cfg).await? {
        CsrfOutcome::Pass(req) => Ok(next.run(req).await),
        CsrfOutcome::Reject(resp) => Ok(resp),
    }
}

/// CSRF middleware with explicit [`CsrfProtectionConfig`].
///
/// Wire with `axum::middleware::from_fn_with_state((db, Arc::new(cfg)),
/// csrf_protection_with_config)` when you need `accept_form_body = true` for
/// SSR HTML forms (no JS, token in a hidden input).
pub async fn csrf_protection_with_config(
    State((_db, cfg)): State<(DatabaseConnection, Arc<CsrfProtectionConfig>)>,
    session: Session,
    uri: Uri,
    request: Request,
    next: Next,
) -> Result<Response, AppError> {
    match csrf_check(&session, &uri, request, cfg.as_ref()).await? {
        CsrfOutcome::Pass(req) => Ok(next.run(req).await),
        CsrfOutcome::Reject(resp) => Ok(resp),
    }
}

/// Internal implementation that takes db by reference instead of via `State`.
///
/// Behaviorally identical to [`csrf_protection`]; kept as a separate entry
/// point for call sites that already have a `&DatabaseConnection` in scope.
pub async fn csrf_protection_impl(
    _db: &DatabaseConnection,
    session: Session,
    uri: Uri,
    request: Request,
    next: Next,
) -> Result<Response, AppError> {
    let cfg = CsrfProtectionConfig::default();
    match csrf_check(&session, &uri, request, &cfg).await? {
        CsrfOutcome::Pass(req) => Ok(next.run(req).await),
        CsrfOutcome::Reject(resp) => Ok(resp),
    }
}

/// Validate CSRF token from form data
///
/// # Arguments
///
/// * `session` - Tower session
/// * `form_token` - CSRF token from form
///
/// # Returns
///
/// Ok(()) if valid, Err(AppError) if invalid
pub async fn validate_csrf_token(session: &Session, form_token: &str) -> Result<(), AppError> {
    let session_token = get_or_create_csrf_token(session).await?;

    // SEC-H4: Constant-time comparison to prevent timing attacks
    if !csrf_tokens_match(form_token, &session_token) {
        tracing::warn!(
            "CSRF token mismatch (expected: {}..., got: {}...)",
            &session_token[..8.min(session_token.len())],
            &form_token[..8.min(form_token.len())]
        );
        return Err(AppError::Forbidden);
    }

    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;
    use axum::body::Body;
    use axum::http::Request as HttpRequest;
    use std::sync::Arc;
    use tower_sessions::MemoryStore;

    // ---------- Form body extraction (pure function) ----------

    #[test]
    fn extract_csrf_from_form_returns_token_value() {
        let body = b"csrf_token=deadbeef";
        assert_eq!(extract_csrf_from_form(body), Some("deadbeef".to_string()));
    }

    #[test]
    fn extract_csrf_from_form_handles_multiple_fields() {
        let body = b"foo=bar&csrf_token=abc123&baz=qux";
        assert_eq!(extract_csrf_from_form(body), Some("abc123".to_string()));
    }

    #[test]
    fn extract_csrf_from_form_url_decodes_token() {
        // 61 = '=' so %3D is a decoded '='
        let body = b"csrf_token=abc%3D%3D";
        assert_eq!(extract_csrf_from_form(body), Some("abc==".to_string()));
    }

    #[test]
    fn extract_csrf_from_form_returns_none_when_absent() {
        let body = b"foo=bar&baz=qux";
        assert_eq!(extract_csrf_from_form(body), None);
    }

    #[test]
    fn extract_csrf_from_form_returns_none_on_invalid_utf8() {
        let body = &[0xff, 0xfe, 0xfd];
        assert_eq!(extract_csrf_from_form(body), None);
    }

    #[test]
    fn test_generate_csrf_token() {
        let token1 = generate_csrf_token();
        let token2 = generate_csrf_token();

        assert_eq!(token1.len(), 64); // 32 bytes = 64 hex chars
        assert_ne!(token1, token2); // Tokens should be different
    }

    // ---------- T12: constant-time token comparison ----------

    #[test]
    fn csrf_tokens_match_is_true_for_equal_tokens() {
        let token = generate_csrf_token();
        assert!(csrf_tokens_match(&token, &token));
    }

    #[test]
    fn csrf_tokens_match_is_false_for_different_tokens() {
        let a = generate_csrf_token();
        let b = generate_csrf_token();
        assert!(!csrf_tokens_match(&a, &b));
    }

    #[test]
    fn csrf_tokens_match_is_false_for_length_mismatch() {
        // `ct_eq` over byte slices of differing lengths must not match.
        assert!(!csrf_tokens_match("abc", "abcd"));
    }

    fn test_session() -> Session {
        Session::new(None, Arc::new(MemoryStore::default()), None)
    }

    fn build_request(method: Method, token_header: Option<&str>, bearer: bool) -> Request {
        let mut builder = HttpRequest::builder().method(method).uri("/resource");
        if let Some(t) = token_header {
            builder = builder.header("X-CSRF-Token", t);
        }
        if bearer {
            builder = builder.header(axum::http::header::AUTHORIZATION, "Bearer some.jwt.token");
        }
        builder.body(Body::empty()).unwrap()
    }

    fn build_form_request(body: &str, content_type: Option<&str>) -> Request {
        let ct = content_type.unwrap_or("application/x-www-form-urlencoded");
        HttpRequest::builder()
            .method(Method::POST)
            .uri("/resource")
            .header(CONTENT_TYPE, ct)
            .body(Body::from(body.to_owned()))
            .unwrap()
    }

    fn uri() -> Uri {
        "/resource".parse().unwrap()
    }

    fn assert_rejected(outcome: CsrfOutcome, expected: StatusCode) {
        match outcome {
            CsrfOutcome::Reject(resp) => assert_eq!(resp.status(), expected),
            CsrfOutcome::Pass(_) => panic!("expected Reject({expected}), got Pass"),
        }
    }

    fn default_cfg() -> CsrfProtectionConfig {
        CsrfProtectionConfig::default()
    }

    fn form_body_cfg() -> CsrfProtectionConfig {
        CsrfProtectionConfig {
            accept_form_body: true,
            max_form_body_bytes: 4096,
        }
    }

    #[tokio::test]
    async fn check_passes_safe_method() {
        let session = test_session();
        let req = build_request(Method::GET, None, false);
        let outcome = csrf_check(&session, &uri(), req, &default_cfg())
            .await
            .unwrap();
        assert!(matches!(outcome, CsrfOutcome::Pass(_)));
    }

    #[tokio::test]
    async fn check_passes_bearer_auth() {
        let session = test_session();
        let req = build_request(Method::POST, None, true);
        let outcome = csrf_check(&session, &uri(), req, &default_cfg())
            .await
            .unwrap();
        assert!(matches!(outcome, CsrfOutcome::Pass(_)));
    }

    #[tokio::test]
    async fn check_accepts_matching_header_token() {
        let session = test_session();
        let token = get_or_create_csrf_token(&session).await.unwrap();
        let req = build_request(Method::POST, Some(&token), false);
        let outcome = csrf_check(&session, &uri(), req, &default_cfg())
            .await
            .unwrap();
        assert!(matches!(outcome, CsrfOutcome::Pass(_)));
    }

    #[tokio::test]
    async fn check_rejects_mismatched_header_token() {
        let session = test_session();
        let _ = get_or_create_csrf_token(&session).await.unwrap();
        let req = build_request(Method::POST, Some("bogus"), false);
        let outcome = csrf_check(&session, &uri(), req, &default_cfg())
            .await
            .unwrap();
        assert_rejected(outcome, StatusCode::FORBIDDEN);
    }

    #[tokio::test]
    async fn check_rejects_missing_token() {
        let session = test_session();
        let req = build_request(Method::POST, None, false);
        let outcome = csrf_check(&session, &uri(), req, &default_cfg())
            .await
            .unwrap();
        assert_rejected(outcome, StatusCode::FORBIDDEN);
    }

    // ---------- Form-body (accept_form_body = true) ----------

    #[tokio::test]
    async fn check_accepts_matching_form_body_token_and_preserves_body() {
        let session = test_session();
        let token = get_or_create_csrf_token(&session).await.unwrap();
        let form = format!("csrf_token={token}&other=value");
        let req = build_form_request(&form, None);

        let outcome = csrf_check(&session, &uri(), req, &form_body_cfg())
            .await
            .unwrap();

        let forwarded = match outcome {
            CsrfOutcome::Pass(r) => r,
            CsrfOutcome::Reject(_) => panic!("expected Pass for valid form token"),
        };
        // Body must survive the buffering so downstream handlers can Form-decode it.
        let (_, body) = forwarded.into_parts();
        let bytes = axum::body::to_bytes(body, 4096).await.unwrap();
        assert_eq!(std::str::from_utf8(&bytes).unwrap(), form);
    }

    #[tokio::test]
    async fn check_rejects_form_body_when_accept_form_body_is_false() {
        let session = test_session();
        let token = get_or_create_csrf_token(&session).await.unwrap();
        let form = format!("csrf_token={token}");
        let req = build_form_request(&form, None);

        // Default config does NOT buffer the body → token unreachable → 403.
        let outcome = csrf_check(&session, &uri(), req, &default_cfg())
            .await
            .unwrap();
        assert_rejected(outcome, StatusCode::FORBIDDEN);
    }

    #[tokio::test]
    async fn check_rejects_form_body_with_mismatched_token() {
        let session = test_session();
        let _ = get_or_create_csrf_token(&session).await.unwrap();
        let req = build_form_request("csrf_token=wrong-token", None);

        let outcome = csrf_check(&session, &uri(), req, &form_body_cfg())
            .await
            .unwrap();
        assert_rejected(outcome, StatusCode::FORBIDDEN);
    }

    #[tokio::test]
    async fn check_rejects_form_body_exceeding_cap() {
        let session = test_session();
        let huge_body = "a=".to_string() + &"x".repeat(1024);
        let cfg = CsrfProtectionConfig {
            accept_form_body: true,
            max_form_body_bytes: 16, // intentionally tiny
        };
        let req = build_form_request(&huge_body, None);

        let outcome = csrf_check(&session, &uri(), req, &cfg).await.unwrap();
        assert_rejected(outcome, StatusCode::PAYLOAD_TOO_LARGE);
    }

    #[tokio::test]
    async fn check_rejects_multipart_body_without_header() {
        let session = test_session();
        let _ = get_or_create_csrf_token(&session).await.unwrap();
        // Multipart is never buffered — security choice, avoids large uploads.
        let req = build_form_request("ignored", Some("multipart/form-data; boundary=----abc"));

        let outcome = csrf_check(&session, &uri(), req, &form_body_cfg())
            .await
            .unwrap();
        assert_rejected(outcome, StatusCode::FORBIDDEN);
    }

    #[tokio::test]
    async fn default_config_matches_header_only_behavior() {
        let cfg = CsrfProtectionConfig::default();
        assert!(!cfg.accept_form_body);
        assert_eq!(cfg.max_form_body_bytes, DEFAULT_MAX_FORM_BODY_BYTES);
    }
}