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
+60 -8
View File
@@ -11,6 +11,7 @@ use socket2::{Domain, Protocol, Socket, TcpKeepalive, Type};
use axum::Router;
use axum::extract::DefaultBodyLimit;
use tower_http::limit::RequestBodyLimitLayer;
use tower_http::set_header::SetResponseHeaderLayer;
use tower_http::trace::TraceLayer;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
@@ -173,6 +174,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
use oxicloud::interfaces::api::handlers::device_auth_handler;
use oxicloud::interfaces::api::handlers::app_password_handler;
use oxicloud::interfaces::middleware::auth::auth_middleware;
use oxicloud::interfaces::middleware::csrf::csrf_middleware;
let auth_router = auth_routes().with_state(app_state.clone());
@@ -182,6 +184,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
.with_state(app_state.clone());
// Protected endpoints: /api/auth/device/verify, /api/auth/device/devices
let device_protected = device_auth_handler::device_auth_protected_routes()
.layer(axum::middleware::from_fn(csrf_middleware))
.layer(axum::middleware::from_fn_with_state(
app_state.clone(),
auth_middleware,
@@ -190,6 +193,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
// App Password management endpoints (protected — require JWT)
let app_password_protected = app_password_handler::app_password_routes()
.layer(axum::middleware::from_fn(csrf_middleware))
.layer(axum::middleware::from_fn_with_state(
app_state.clone(),
auth_middleware,
@@ -197,10 +201,12 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
.with_state(app_state.clone());
// Protected API routes — require valid JWT token
let protected_api = api_routes.layer(axum::middleware::from_fn_with_state(
app_state.clone(),
auth_middleware,
));
let protected_api = api_routes
.layer(axum::middleware::from_fn(csrf_middleware))
.layer(axum::middleware::from_fn_with_state(
app_state.clone(),
auth_middleware,
));
// CalDAV/CardDAV/WebDAV with auth middleware (merged, not nested)
let caldav_protected = caldav_router.layer(axum::middleware::from_fn_with_state(
@@ -240,10 +246,12 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
// Mount WOPI routes (protocol routes use own token auth, API routes behind auth middleware)
if let Some((wopi_protocol, wopi_api)) = wopi_routes {
let wopi_api_protected = wopi_api.layer(axum::middleware::from_fn_with_state(
app_state.clone(),
auth_middleware,
));
let wopi_api_protected = wopi_api
.layer(axum::middleware::from_fn(csrf_middleware))
.layer(axum::middleware::from_fn_with_state(
app_state.clone(),
auth_middleware,
));
app = app
.nest("/wopi", wopi_protocol)
.nest("/api/wopi", wopi_api_protected);
@@ -273,6 +281,50 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
// Without this Axum caps Multipart bodies at 2 MB.
app = app.layer(DefaultBodyLimit::max(10 * 1024 * 1024 * 1024));
// ── Security headers ─────────────────────────────────────────────────
// Applied globally so every response (API, static, DAV) carries them.
use axum::http::header::HeaderName;
use axum::http::HeaderValue;
app = app
.layer(SetResponseHeaderLayer::overriding(
HeaderName::from_static("content-security-policy"),
// NOTE: script-src includes 'unsafe-inline' because several HTML
// pages still use inline event handlers (onclick, onsubmit) and
// <script> blocks. TODO: migrate these to external .js files so
// 'unsafe-inline' can be removed.
// frame-src is permissive (*) to allow WOPI editor iframes whose
// origin is configured at runtime (Collabora, OnlyOffice, etc.).
HeaderValue::from_static(
"default-src 'self'; \
script-src 'self' 'unsafe-inline'; \
style-src 'self' 'unsafe-inline'; \
img-src 'self' data: blob:; \
connect-src 'self'; \
font-src 'self' data:; \
frame-src *; \
frame-ancestors 'none'; \
base-uri 'self'; \
form-action 'self'"
),
))
.layer(SetResponseHeaderLayer::overriding(
HeaderName::from_static("x-content-type-options"),
HeaderValue::from_static("nosniff"),
))
.layer(SetResponseHeaderLayer::overriding(
HeaderName::from_static("x-frame-options"),
HeaderValue::from_static("DENY"),
))
.layer(SetResponseHeaderLayer::overriding(
HeaderName::from_static("referrer-policy"),
HeaderValue::from_static("strict-origin-when-cross-origin"),
))
.layer(SetResponseHeaderLayer::overriding(
HeaderName::from_static("permissions-policy"),
HeaderValue::from_static("camera=(), microphone=(), geolocation=()"),
));
// Start server — tuned socket for low-latency responses
let addr = SocketAddr::from(([0, 0, 0, 0], 8086));
tracing::info!("Starting OxiCloud server on http://{}", addr);