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:
@@ -0,0 +1,148 @@
|
||||
//! HttpOnly cookie helpers for secure token transport.
|
||||
//!
|
||||
//! Tokens are set as `HttpOnly; SameSite=Lax` cookies so that
|
||||
//! browser-based JavaScript cannot read them (mitigates XSS token theft).
|
||||
//! The `Secure` flag is controlled by the `OXICLOUD_COOKIE_SECURE` env var
|
||||
//! (default: auto-detect from `OXICLOUD_BASE_URL`).
|
||||
//!
|
||||
//! A companion **non-HttpOnly** CSRF cookie (`oxicloud_csrf`) is set
|
||||
//! alongside the auth cookies. The frontend must read it and echo its
|
||||
//! value back as `X-CSRF-Token` on every state-changing request.
|
||||
//! A middleware (`csrf_middleware`) validates the match.
|
||||
//!
|
||||
//! DAV clients continue to use `Authorization: Basic` with app passwords
|
||||
//! and are completely unaffected by this mechanism.
|
||||
|
||||
use axum::http::header::SET_COOKIE;
|
||||
use axum::http::{HeaderMap, HeaderValue};
|
||||
|
||||
/// Cookie name for the JWT access token.
|
||||
pub const ACCESS_COOKIE: &str = "oxicloud_access";
|
||||
/// Cookie name for the opaque refresh token.
|
||||
pub const REFRESH_COOKIE: &str = "oxicloud_refresh";
|
||||
/// Cookie name for the CSRF double-submit token (readable by JS).
|
||||
pub const CSRF_COOKIE: &str = "oxicloud_csrf";
|
||||
/// Header the frontend must send with the CSRF token value.
|
||||
pub const CSRF_HEADER: &str = "x-csrf-token";
|
||||
|
||||
/// Whether the `Secure` flag should be set on cookies.
|
||||
/// Auto-detected from `OXICLOUD_BASE_URL` (if it starts with `https`)
|
||||
/// or overridden with `OXICLOUD_COOKIE_SECURE=true|false`.
|
||||
fn cookie_secure() -> bool {
|
||||
if let Ok(v) = std::env::var("OXICLOUD_COOKIE_SECURE") {
|
||||
return v == "true" || v == "1";
|
||||
}
|
||||
// Auto-detect from base URL
|
||||
std::env::var("OXICLOUD_BASE_URL")
|
||||
.map(|u| u.starts_with("https"))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Build a `Set-Cookie` header value.
|
||||
fn build_cookie(name: &str, value: &str, path: &str, max_age_secs: i64) -> String {
|
||||
let secure = if cookie_secure() { "; Secure" } else { "" };
|
||||
format!(
|
||||
"{name}={value}; HttpOnly; SameSite=Lax; Path={path}; Max-Age={max_age_secs}{secure}",
|
||||
)
|
||||
}
|
||||
|
||||
/// Append `Set-Cookie` headers for both access and refresh tokens.
|
||||
///
|
||||
/// The access cookie covers all paths (`/`) because the API lives under
|
||||
/// `/api`, CalDAV under `/caldav`, WebDAV under `/webdav`, etc.
|
||||
///
|
||||
/// The refresh cookie is restricted to `/api/auth` so it is only sent
|
||||
/// when the client explicitly calls the refresh or logout endpoints.
|
||||
pub fn append_auth_cookies(
|
||||
headers: &mut HeaderMap,
|
||||
access_token: &str,
|
||||
refresh_token: &str,
|
||||
access_expiry_secs: i64,
|
||||
refresh_expiry_secs: i64,
|
||||
) {
|
||||
if let Ok(val) = HeaderValue::from_str(&build_cookie(
|
||||
ACCESS_COOKIE,
|
||||
access_token,
|
||||
"/",
|
||||
access_expiry_secs,
|
||||
)) {
|
||||
headers.append(SET_COOKIE, val);
|
||||
}
|
||||
if let Ok(val) = HeaderValue::from_str(&build_cookie(
|
||||
REFRESH_COOKIE,
|
||||
refresh_token,
|
||||
"/api/auth",
|
||||
refresh_expiry_secs,
|
||||
)) {
|
||||
headers.append(SET_COOKIE, val);
|
||||
}
|
||||
}
|
||||
|
||||
/// Append `Set-Cookie` headers that immediately expire both auth cookies,
|
||||
/// effectively logging the user out on the browser side.
|
||||
pub fn append_clear_cookies(headers: &mut HeaderMap) {
|
||||
for (name, path) in [(ACCESS_COOKIE, "/"), (REFRESH_COOKIE, "/api/auth")] {
|
||||
let secure = if cookie_secure() { "; Secure" } else { "" };
|
||||
let val = format!(
|
||||
"{name}=; HttpOnly; SameSite=Lax; Path={path}; Max-Age=0{secure}",
|
||||
);
|
||||
if let Ok(hv) = HeaderValue::from_str(&val) {
|
||||
headers.append(SET_COOKIE, hv);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract a named cookie value from the `Cookie` request header.
|
||||
pub fn extract_cookie_value(headers: &HeaderMap, name: &str) -> Option<String> {
|
||||
let cookie_header = headers.get(axum::http::header::COOKIE)?;
|
||||
let cookie_str = cookie_header.to_str().ok()?;
|
||||
|
||||
for pair in cookie_str.split(';') {
|
||||
let pair = pair.trim();
|
||||
if let Some(val) = pair.strip_prefix(name) {
|
||||
let val = val.strip_prefix('=')?;
|
||||
if !val.is_empty() {
|
||||
return Some(val.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// CSRF double-submit cookie helpers
|
||||
// ────────────────────────────────────────────────────────────
|
||||
|
||||
/// Generate a cryptographically random CSRF token (128-bit UUIDv4, hex-like).
|
||||
pub fn generate_csrf_token() -> String {
|
||||
uuid::Uuid::new_v4().to_string()
|
||||
}
|
||||
|
||||
/// Build a **non-HttpOnly** CSRF cookie so that frontend JS can read it
|
||||
/// via `document.cookie` and echo it back in the `X-CSRF-Token` header.
|
||||
fn build_csrf_cookie(value: &str, max_age_secs: i64) -> String {
|
||||
let secure = if cookie_secure() { "; Secure" } else { "" };
|
||||
format!(
|
||||
"{CSRF_COOKIE}={value}; SameSite=Lax; Path=/; Max-Age={max_age_secs}{secure}",
|
||||
)
|
||||
}
|
||||
|
||||
/// Append a CSRF double-submit cookie alongside the auth cookies.
|
||||
/// Should be called in every endpoint that also sets auth cookies.
|
||||
pub fn append_csrf_cookie(headers: &mut HeaderMap, access_expiry_secs: i64) {
|
||||
let token = generate_csrf_token();
|
||||
if let Ok(val) = HeaderValue::from_str(&build_csrf_cookie(&token, access_expiry_secs)) {
|
||||
headers.append(SET_COOKIE, val);
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear the CSRF cookie (on logout).
|
||||
pub fn append_clear_csrf_cookie(headers: &mut HeaderMap) {
|
||||
let secure = if cookie_secure() { "; Secure" } else { "" };
|
||||
let val = format!(
|
||||
"{CSRF_COOKIE}=; SameSite=Lax; Path=/; Max-Age=0{secure}",
|
||||
);
|
||||
if let Ok(hv) = HeaderValue::from_str(&val) {
|
||||
headers.append(SET_COOKIE, hv);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user