feat(security): HttpOnly cookies + CSP headers + CSRF double-submit protection
- Migrate auth tokens from localStorage to HttpOnly SameSite=Lax cookies - Add cookie_auth.rs: helpers for setting/clearing auth + CSRF cookies - Update auth middleware: 3-method auth (Bearer → Basic → Cookie) - Add 5 security headers: CSP, X-Content-Type-Options, X-Frame-Options, Referrer-Policy, Permissions-Policy - Implement CSRF double-submit cookie pattern (csrf.rs middleware) - Set CSRF cookie on login/refresh/oidc-exchange, clear on logout - CookieAuthenticated marker skips CSRF for Bearer/Basic clients - Frontend: strip all localStorage token refs from 14 JS files - Frontend: csrf.js utility + all 52 mutating fetch/XHR calls protected - 121 tests passing, 0 warnings
This commit is contained in:
@@ -12,6 +12,12 @@ use crate::common::di::AppState;
|
||||
// Re-export CurrentUser from application layer for use in handlers
|
||||
pub use crate::application::dtos::user_dto::CurrentUser;
|
||||
|
||||
/// Marker inserted into request extensions when the user was authenticated
|
||||
/// via the `oxicloud_access` HttpOnly cookie rather than a Bearer/Basic header.
|
||||
/// The CSRF middleware uses this to decide whether CSRF validation is required.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct CookieAuthenticated;
|
||||
|
||||
// Structure for use in Axum extractors
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct AuthUser {
|
||||
@@ -154,12 +160,15 @@ impl IntoResponse for AuthError {
|
||||
|
||||
/// Secure authentication middleware.
|
||||
///
|
||||
/// Supports two authentication methods:
|
||||
/// Supports three authentication methods (tried in order):
|
||||
/// 1. **Bearer JWT** — standard token in `Authorization: Bearer <token>`
|
||||
/// 2. **Basic Auth with App Passwords** — for DAV clients (DAVx⁵, Thunderbird, rclone)
|
||||
/// that send `Authorization: Basic base64(username:app_password)`
|
||||
/// 3. **HttpOnly Cookie** — `oxicloud_access` cookie set by the login endpoint;
|
||||
/// used by browser-based sessions so tokens are never exposed to JS.
|
||||
///
|
||||
/// Bearer is tried first; if no Bearer header is found, Basic is attempted.
|
||||
/// Bearer is tried first; if no Bearer header is found, Basic is attempted,
|
||||
/// then the cookie fallback.
|
||||
pub async fn auth_middleware(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
@@ -260,7 +269,44 @@ pub async fn auth_middleware(
|
||||
}
|
||||
}
|
||||
|
||||
// No valid Authorization header found
|
||||
// ── 3. Try HttpOnly cookie (browser sessions) ────────────────
|
||||
{
|
||||
use crate::interfaces::api::cookie_auth;
|
||||
|
||||
if let Some(token_str) = cookie_auth::extract_cookie_value(&headers, cookie_auth::ACCESS_COOKIE) {
|
||||
if !token_str.is_empty() {
|
||||
tracing::debug!("Processing cookie-based authentication");
|
||||
|
||||
if let Some(auth_service) = state.auth_service.as_ref() {
|
||||
let token_service = &auth_service.token_service;
|
||||
match token_service.validate_token(&token_str) {
|
||||
Ok(claims) => {
|
||||
tracing::debug!(
|
||||
"Cookie token validated for user: {}",
|
||||
claims.username
|
||||
);
|
||||
let current_user = CurrentUser {
|
||||
id: claims.sub,
|
||||
username: claims.username,
|
||||
email: claims.email,
|
||||
role: claims.role,
|
||||
};
|
||||
request.extensions_mut().insert(current_user);
|
||||
request.extensions_mut().insert(CookieAuthenticated);
|
||||
return Ok(next.run(request).await);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::debug!("Cookie token validation failed: {}", e);
|
||||
// Don't return error — fall through to "no token" so
|
||||
// the browser gets a 401 and can redirect to /login.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// No valid credentials found via any method
|
||||
if state.auth_service.is_none() {
|
||||
tracing::error!("Auth middleware invoked but auth service is not configured");
|
||||
return Err(AuthError::AuthServiceUnavailable);
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
//! CSRF double-submit cookie middleware.
|
||||
//!
|
||||
//! State-changing requests (`POST`, `PUT`, `DELETE`, `PATCH`) that were
|
||||
//! authenticated via an HttpOnly cookie (i.e. browser sessions) **must**
|
||||
//! include an `X-CSRF-Token` header whose value matches the `oxicloud_csrf`
|
||||
//! cookie. Requests authenticated via `Bearer` or `Basic` headers are
|
||||
//! exempt because they are not vulnerable to CSRF — the browser never
|
||||
//! attaches those automatically.
|
||||
//!
|
||||
//! Safe methods (`GET`, `HEAD`, `OPTIONS`) are always allowed through.
|
||||
|
||||
use axum::{
|
||||
extract::Request,
|
||||
http::{Method, StatusCode},
|
||||
middleware::Next,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
|
||||
use crate::interfaces::api::cookie_auth;
|
||||
use crate::interfaces::middleware::auth::CookieAuthenticated;
|
||||
|
||||
/// Methods considered safe (no side-effects) — CSRF check is skipped.
|
||||
const SAFE_METHODS: [Method; 3] = [Method::GET, Method::HEAD, Method::OPTIONS];
|
||||
|
||||
/// Middleware that enforces CSRF protection for cookie-authenticated browser
|
||||
/// sessions using the **double-submit cookie** pattern.
|
||||
///
|
||||
/// Must be applied **after** `auth_middleware` so that the
|
||||
/// `CookieAuthenticated` marker is available in extensions.
|
||||
pub async fn csrf_middleware(request: Request, next: Next) -> Result<Response, Response> {
|
||||
// Safe methods never need CSRF validation.
|
||||
if SAFE_METHODS.contains(request.method()) {
|
||||
return Ok(next.run(request).await);
|
||||
}
|
||||
|
||||
// Only enforce for cookie-authenticated sessions.
|
||||
let is_cookie_auth = request.extensions().get::<CookieAuthenticated>().is_some();
|
||||
if !is_cookie_auth {
|
||||
return Ok(next.run(request).await);
|
||||
}
|
||||
|
||||
// Extract the CSRF token from the cookie.
|
||||
let cookie_token = cookie_auth::extract_cookie_value(
|
||||
request.headers(),
|
||||
cookie_auth::CSRF_COOKIE,
|
||||
);
|
||||
|
||||
// Extract the CSRF token from the request header.
|
||||
let header_token = request
|
||||
.headers()
|
||||
.get(cookie_auth::CSRF_HEADER)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
match (cookie_token, header_token) {
|
||||
(Some(c), Some(h)) if !c.is_empty() && c == h => {
|
||||
// Tokens match — allow the request through.
|
||||
Ok(next.run(request).await)
|
||||
}
|
||||
_ => {
|
||||
tracing::warn!(
|
||||
method = %request.method(),
|
||||
uri = %request.uri(),
|
||||
"CSRF validation failed: missing or mismatched token"
|
||||
);
|
||||
Err((
|
||||
StatusCode::FORBIDDEN,
|
||||
axum::Json(serde_json::json!({
|
||||
"error": "CSRF token missing or invalid"
|
||||
})),
|
||||
)
|
||||
.into_response())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1 +1,2 @@
|
||||
pub mod auth;
|
||||
pub mod csrf;
|
||||
|
||||
Reference in New Issue
Block a user