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:
Dionisio
2026-03-03 01:10:50 +01:00
parent 7b2a8577a9
commit d2c08d31ba
27 changed files with 579 additions and 455 deletions
+49 -3
View File
@@ -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);
+75
View File
@@ -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
View File
@@ -1 +1,2 @@
pub mod auth;
pub mod csrf;