app.rs
1551 lignes · 62506 octets
//! Application builder for Rustwarden //! //! Provides a builder pattern to configure and launch a Rustwarden application. //! //! # Example (with default UI) //! //! ```rust,ignore //! use rustwarden_core::app::RustwardenApp; //! //! #[tokio::main] //! async fn main() -> anyhow::Result<()> { //! RustwardenApp::builder() //! .from_env() //! .build() //! .await? //! .run() //! .await //! } //! ``` use axum::{ Router, extract::Request, http::{HeaderValue, Method, header::HeaderName}, middleware::Next, response::Response, }; use sea_orm::DatabaseConnection; use std::path::PathBuf; use std::sync::Arc; use tower::ServiceBuilder; use tower_http::cors::{AllowOrigin, Any, CorsLayer}; use crate::auth::session::SessionConfig; use crate::config::analytics::{AnalyticsConfig, AnalyticsState}; use crate::config::auth::AuthConfig; use crate::config::database; use crate::config::email::EmailConfig; use crate::config::security_headers::{CSP_WITHOUT_UNSAFE_EVAL, SecurityHeadersConfig}; use crate::config::server::ServerConfig; use crate::dashboard::DashboardCardDTO; use crate::hooks::{NoopHooks, RustwardenHooks}; use crate::i18n::extractor::Locale; use crate::i18n::loader::load_translations_from_directory; use crate::i18n::service::I18nService; use crate::middleware::admin_service::AdminService; use crate::middleware::auth_service::AuthService; use crate::middleware::csrf::{ CsrfProtectionConfig, DEFAULT_MAX_FORM_BODY_BYTES, csrf_protection_with_config, }; use crate::services::app_settings_service::AppSettingsService; use crate::services::default_admin_service; /// A fully configured Rustwarden application ready to run. /// /// After `build()`, getters expose internal resources (DB, config, i18n) /// so consumer apps can run migrations, seed data, or translate messages /// before calling `run()`. pub struct RustwardenApp { router: Router, address: String, db: DatabaseConnection, auth_config: Arc<AuthConfig>, i18n: Arc<I18nService>, } impl RustwardenApp { /// Create a new builder to configure the application. pub fn builder() -> RustwardenBuilder { RustwardenBuilder::default() } /// Access the database connection (pool) used by the framework. /// /// Allows consumer apps to run business migrations, seed data, or /// execute queries on the same connection pool as the framework. /// /// # Examples /// /// ```rust,ignore /// let app = RustwardenApp::builder().from_env().build().await?; /// my_app::migrations::run_migrations(app.database()).await?; /// app.run().await /// ``` pub fn database(&self) -> &DatabaseConnection { &self.db } /// Access the authentication configuration. /// /// Exposes JWT settings, session timeouts, rate limits, and cookie /// configuration for consumer app logic that depends on auth parameters. pub fn auth_config(&self) -> &AuthConfig { &self.auth_config } /// Access the i18n translation service. /// /// Allows consumer apps to translate messages in non-handler code /// (cron tasks, seeding, CLI) or load additional translations after build. pub fn i18n_service(&self) -> &Arc<I18nService> { &self.i18n } /// Server bind address (e.g. "0.0.0.0:3000"). pub fn address(&self) -> &str { &self.address } /// Get the assembled router (for advanced usage, e.g. testing). /// /// Callers who serve this router themselves **must** use /// [`axum::Router::into_make_service_with_connect_info`] with /// [`std::net::SocketAddr`]. Without that extension, the auth IP /// rate-limit cannot see the peer address and keys every request as /// `unknown`. [`RustwardenApp::run`] does this automatically. pub fn into_router(self) -> Router { self.router } /// Start serving HTTP requests. pub async fn run(self) -> anyhow::Result<()> { let listener = tokio::net::TcpListener::bind(&self.address).await?; tracing::info!("Server listening on http://{}", self.address); axum::serve( listener, self.router .into_make_service_with_connect_info::<std::net::SocketAddr>(), ) .await .map_err(|e| anyhow::anyhow!("Server error: {}", e)) } } type RoutesFn = Box<dyn FnOnce() -> Router<DatabaseConnection> + Send>; /// Builder for configuring a Rustwarden application. pub struct RustwardenBuilder { database_url: Option<String>, db: Option<DatabaseConnection>, server_address: Option<String>, /// Browser-family routers (session-cookie driven UI). These receive the /// CSRF protection layer before being merged into the application router. /// Fed by [`RustwardenBuilder::merge_routes`] / /// [`RustwardenBuilder::merge_browser_routes`]. custom_routes: Vec<Router<DatabaseConnection>>, /// Machine-family routers (JSON APIs, webhooks, ActivityPub inbox, OAuth /// callbacks). These are merged *without* the CSRF layer. Fed by /// [`RustwardenBuilder::merge_machine_routes`]. machine_routes: Vec<Router<DatabaseConnection>>, ui_routes_fn: Option<RoutesFn>, hooks: Arc<dyn RustwardenHooks>, auto_migrate: bool, enable_email: bool, enable_background_tasks: bool, locale_dirs: Vec<PathBuf>, supported_locales: Vec<String>, default_locale: String, /// URL path prefixes that the authentication middleware must protect. /// Empty by default; [`RustwardenBuilder::with_ui`] registers the default /// UI areas (`/home`, `/profile`, `/settings`, `/admin`) and companion /// crates / consumers add their own via `register_protected_prefix`. pub(crate) protected_prefixes: Vec<String>, /// Optional revocation checker consulted by `AuthService` for remote /// JWTs (RFC 0018 / F10). Injected by `rustwarden-fediverse` when /// the consumer mounts its routes; left unset for headless apps. pub(crate) revocation_checker: Option<Arc<dyn crate::middleware::revocation::RemoteIdentityRevocationChecker>>, /// Extra cards rendered at the bottom of the admin dashboard. /// Filled by companion crates (e.g. `rustwarden-fediverse-ui`) via /// [`RustwardenBuilder::add_dashboard_card`]; injected as an Axum /// `Extension<Vec<DashboardCardDTO>>` so `admin_dashboard` can pick /// them up at render time. pub(crate) dashboard_cards: Vec<DashboardCardDTO>, /// Override for [`crate::config::auth::AuthConfig::login_path`] applied at /// build time. `None` keeps the value resolved from the environment. login_path: Option<String>, /// Override for [`crate::config::auth::AuthConfig::auth_failure_mode`]. /// `None` keeps the env/default value, unless `headless` flips it. auth_failure_mode: Option<crate::config::auth::AuthFailureMode>, /// Set by [`RustwardenBuilder::headless`]; flips the default auth-failure /// mode to `Json401` when no explicit mode was requested. headless: bool, /// Security response headers applied by the security headers middleware /// (RFC 0039). Defaults to the 1.0.1-compatible policy: legacy CSP, /// no COOP/CORP. Overridden via [`RustwardenBuilder::security_headers`]. security_headers: SecurityHeadersConfig, /// Audience-measurement configuration. Defaults to disabled, which /// renders nothing and leaves the CSP untouched. Set via /// [`RustwardenBuilder::analytics`]. analytics: AnalyticsConfig, } impl Default for RustwardenBuilder { fn default() -> Self { Self { database_url: None, db: None, server_address: None, custom_routes: Vec::new(), machine_routes: Vec::new(), ui_routes_fn: None, hooks: Arc::new(NoopHooks), auto_migrate: false, enable_email: true, enable_background_tasks: true, locale_dirs: Vec::new(), supported_locales: vec!["fr".into(), "en".into(), "de".into(), "es".into()], default_locale: "fr".into(), // Empty by default so headless (API-only) apps carry no // UI-specific prefixes. `with_ui` registers the default UI set; // consumers add their own via `register_protected_prefix`. protected_prefixes: Vec::new(), revocation_checker: None, dashboard_cards: Vec::new(), login_path: None, auth_failure_mode: None, headless: false, security_headers: SecurityHeadersConfig::default(), analytics: AnalyticsConfig::default(), } } } impl RustwardenBuilder { /// Load configuration from environment variables (.env file). pub fn from_env(mut self) -> Self { dotenvy::dotenv().ok(); tracing_subscriber::fmt() .with_env_filter( tracing_subscriber::EnvFilter::try_from_default_env() .unwrap_or_else(|_| "rustwarden_core=debug,rustwarden_ui=debug".into()), ) .init(); self.database_url = std::env::var("DATABASE_URL").ok(); self } /// Set the database URL explicitly. pub fn database_url(mut self, url: impl Into<String>) -> Self { self.database_url = Some(url.into()); self } /// Provide an already-established database connection. pub fn database(mut self, db: DatabaseConnection) -> Self { self.db = Some(db); self } /// Set the server bind address (e.g. "0.0.0.0:3000"). pub fn address(mut self, addr: impl Into<String>) -> Self { self.server_address = Some(addr.into()); self } /// Override security headers (CSP / optional COOP / CORP). /// /// Default: [`SecurityHeadersConfig::default`] (current 1.0.1 CSP, /// no COOP/CORP), so consumers that never call this method keep /// bit-identical response headers. Call this to supply a stricter /// `Content-Security-Policy` or to opt into cross-origin isolation /// (e.g. Gitrust dropping its `SetResponseHeaderLayer::overriding` /// workaround — RFC 0039). pub fn security_headers(mut self, cfg: SecurityHeadersConfig) -> Self { self.security_headers = cfg; self } /// Configure audience measurement (analytics). /// /// Default: [`AnalyticsConfig::default`] — disabled, rendering nothing /// and leaving the CSP untouched. /// /// When a script URL is configured, [`build`](Self::build) allows its /// origin in the `script-src` and `connect-src` CSP directives. That /// happens **at build time, not here**, so the order of the /// `.analytics(..)` and `.security_headers(..)` calls does not matter: a /// later `.security_headers(..)` cannot silently drop the allowance. /// /// The runtime toggle, site identifier and extra attributes can be /// overridden from the `app_settings` table; the script URL cannot, since /// the CSP is frozen once the router is built. See /// [`crate::config::analytics`]. /// /// # Examples /// /// ```no_run /// use rustwarden_core::app::RustwardenApp; /// use rustwarden_core::config::analytics::AnalyticsConfig; /// /// let builder = RustwardenApp::builder().analytics( /// AnalyticsConfig::default() /// .with_script_url("https://stats.example.org/api/script.js") /// .with_site_id("abc123") /// .with_opt_out_storage_key("disable-rybbit") /// .enabled(true), /// ); /// ``` pub fn analytics(mut self, cfg: AnalyticsConfig) -> Self { self.analytics = cfg; self } /// Merge additional custom routes into the **browser** family. /// /// Browser routes are session-cookie driven and therefore receive the /// CSRF protection layer: any `POST`/`PUT`/`DELETE`/`PATCH` reaching a /// route merged here is rejected with `403` unless it carries a valid /// CSRF token (header `X-CSRF-Token` or form field `csrf_token`), or an /// `Authorization: Bearer` header. This is the fail-safe default (D2=A): /// a third-party mutating route inherits protection without extra code. /// /// Routes that authenticate via something other than a session cookie /// (JSON APIs, webhooks, ActivityPub inbox, OAuth callbacks) must be /// mounted via [`RustwardenBuilder::merge_machine_routes`] instead, or /// they will receive spurious `403`s. pub fn merge_routes(mut self, router: Router<DatabaseConnection>) -> Self { self.custom_routes.push(router); self } /// Explicit alias of [`RustwardenBuilder::merge_routes`]: merge routes /// into the **browser** family (CSRF-protected). Prefer this name at call /// sites where the browser/machine distinction matters for readability. pub fn merge_browser_routes(self, router: Router<DatabaseConnection>) -> Self { self.merge_routes(router) } /// Merge additional custom routes into the **machine** family. /// /// Machine routes are *not* covered by the CSRF layer. Use this channel /// for endpoints that are not browser/session-cookie driven and thus not /// CSRF-vulnerable: JSON APIs authenticated by `Authorization: Bearer`, /// webhooks, OAuth callbacks, and the ActivityPub inbox (authenticated by /// HTTP signatures, where no CSRF token can exist). pub fn merge_machine_routes(mut self, router: Router<DatabaseConnection>) -> Self { self.machine_routes.push(router); self } /// Register the default UI routes (login, profile, admin panel). /// Pass the function that returns the UI router (from `rustwarden_ui::default_routes`). /// /// This also registers the authenticated UI areas (`/home`, `/profile`, /// `/settings`, `/admin`) as protected prefixes, so the default UI stays /// protected without the core hard-wiring these paths for every app. /// Headless apps, which never call `with_ui`, do not inherit them. pub fn with_ui( mut self, routes_fn: impl FnOnce() -> Router<DatabaseConnection> + Send + 'static, ) -> Self { self.ui_routes_fn = Some(Box::new(routes_fn)); for prefix in ["/home", "/profile", "/settings", "/admin"] { if !self.protected_prefixes.iter().any(|p| p == prefix) { self.protected_prefixes.push(prefix.to_string()); } } self } /// Register custom lifecycle hooks. pub fn hooks(mut self, hooks: impl RustwardenHooks + 'static) -> Self { self.hooks = Arc::new(hooks); self } /// Run in headless mode (API-only, no UI). /// /// Besides dropping the default UI routes, this flips the *default* /// authentication-failure behaviour to [`AuthFailureMode::Json401`] so /// protected prefixes, `/admin*` and `/api/v1/admin*` answer /// unauthenticated requests with `401` JSON instead of redirecting to a /// `/login` page that does not exist. /// /// It is only a default: an explicit /// [`RustwardenBuilder::auth_failure_mode`] call wins, and so does an /// explicit `AUTH_FAILURE_MODE` environment variable (RFC 0042). Since /// 1.2.0 the flip is applied by every enforcement point, including the /// prefix middlewares `AuthService` / `AdminService`. /// /// [`AuthFailureMode::Json401`]: crate::config::auth::AuthFailureMode::Json401 pub fn headless(mut self) -> Self { self.ui_routes_fn = None; self.headless = true; self } /// Override the browser login redirect path /// ([`crate::config::auth::AuthConfig::login_path`], default `/login`). /// /// Precedence: this setter, then the `LOGIN_PATH` env value, then `/login`. /// Honoured by the /// prefix middlewares (`AuthService`, `AdminService`), the extractors /// (`AuthUser`, `auth_required`, `admin_required`) and the UI root /// redirect (RFC 0042). pub fn login_path(mut self, path: impl Into<String>) -> Self { self.login_path = Some(path.into()); self } /// Override how the auth middleware answers unauthenticated requests to /// protected routes /// ([`crate::config::auth::AuthConfig::auth_failure_mode`]). /// /// Precedence: this setter, then the `AUTH_FAILURE_MODE` env value, then /// the default flipped by [`RustwardenBuilder::headless`], then `Redirect`. /// Honoured by /// every enforcement point since 1.2.0 (RFC 0042). pub fn auth_failure_mode(mut self, mode: crate::config::auth::AuthFailureMode) -> Self { self.auth_failure_mode = Some(mode); self } /// Register a URL path prefix that the authentication middleware must /// protect (redirect anonymous visitors to the login page, or reply /// `401` in headless mode). The set is empty by default; /// [`RustwardenBuilder::with_ui`] registers the default UI areas /// (`/home`, `/profile`, `/settings`, `/admin`), and consumers and /// companion crates add more on top — for instance /// `rustwarden-fediverse::mount` adds `/fediverse/consent` and /// `/fediverse/invite`. pub fn register_protected_prefix(mut self, prefix: impl Into<String>) -> Self { self.protected_prefixes.push(prefix.into()); self } /// Register an implementation of /// [`crate::middleware::revocation::RemoteIdentityRevocationChecker`] /// so the auth middleware can refuse remote JWTs whose backing /// identity has been disabled (RFC 0018 / F10). Called by /// `rustwarden_fediverse::mount` ; headless apps without federation /// leave this unset and the middleware skips the check. pub fn register_revocation_checker( mut self, checker: Arc<dyn crate::middleware::revocation::RemoteIdentityRevocationChecker>, ) -> Self { self.revocation_checker = Some(checker); self } /// Append a card to the admin dashboard. /// /// The card is exposed to handlers as part of an Axum /// `Extension<Vec<DashboardCardDTO>>` and rendered by /// `admin_dashboard` after the built-in cards. Companion crates /// (e.g. `rustwarden-fediverse-ui`) ship a constructor returning a /// pre-filled DTO so the consumer only has to pipe it into the /// builder. pub fn add_dashboard_card(mut self, card: DashboardCardDTO) -> Self { self.dashboard_cards.push(card); self } /// Enable automatic database migration at startup. pub fn auto_migrate(mut self, enabled: bool) -> Self { self.auto_migrate = enabled; self } /// Disable email features. pub fn without_email(mut self) -> Self { self.enable_email = false; self } /// Disable background tasks (email queue, JWT cleanup, etc.). pub fn without_background_tasks(mut self) -> Self { self.enable_background_tasks = false; self } /// Add a directory to search for locale YAML files. pub fn locale_dir(mut self, path: impl Into<PathBuf>) -> Self { self.locale_dirs.push(path.into()); self } /// Set supported locales (default: fr, en, de, es). pub fn supported_locales(mut self, locales: Vec<String>) -> Self { self.supported_locales = locales; self } /// Set the default locale (default: "fr"). pub fn default_locale(mut self, locale: impl Into<String>) -> Self { self.default_locale = locale.into(); self } /// Apply the builder overrides on top of the environment-loaded config. /// /// Pure: the environment is never read here. `failure_mode_set_in_env` /// tells whether `AUTH_FAILURE_MODE` was explicitly set, so an operator /// value keeps precedence over the `headless()` default flip (RFC 0042). /// /// Precedence for `auth_failure_mode`: the `auth_failure_mode()` setter, /// then the `AUTH_FAILURE_MODE` env value, then the `headless()` flip to /// `Json401`, then `Redirect`. /// Precedence for `login_path`: the `login_path()` setter, then the /// `LOGIN_PATH` env value, then `/login`. /// Whether a *valid* `AUTH_FAILURE_MODE` value is present in the /// environment. Pure: takes the raw value so it can be unit-tested. /// /// Presence alone is not enough: `AuthConfig::from_env` silently falls /// back to `Redirect` when the value does not parse (empty string, typo), /// and that fallback must not suppress the `headless()` flip. fn failure_mode_overridden_by_env(raw: Option<&str>) -> bool { raw.and_then(|value| value.parse::<crate::config::auth::AuthFailureMode>().ok()) .is_some() } fn apply_auth_overrides( &self, mut cfg: AuthConfig, failure_mode_set_in_env: bool, ) -> AuthConfig { if let Some(path) = &self.login_path { cfg.login_path = path.clone(); } if let Some(mode) = self.auth_failure_mode { cfg.auth_failure_mode = mode; } else if self.headless && !failure_mode_set_in_env { cfg.auth_failure_mode = crate::config::auth::AuthFailureMode::Json401; } cfg } /// Build the application. Connects to the database, initializes services, /// and assembles the router with all middleware. pub async fn build(self) -> anyhow::Result<RustwardenApp> { tracing::info!("Building Rustwarden application..."); // 0. Auth config (SEC-M1: load once, share via Extension; RFC 0042: // the resolved config is also handed to `AuthService` / // `AdminService` at construction so builder overrides reach them). // Resolved before the database is taken out of `self`, as the // overrides live on the builder. let failure_mode_set_in_env = Self::failure_mode_overridden_by_env( std::env::var("AUTH_FAILURE_MODE").ok().as_deref(), ); let auth_config = self.apply_auth_overrides( AuthConfig::from_env().map_err(|e| anyhow::anyhow!("Auth config error: {}", e))?, failure_mode_set_in_env, ); // 1. Database connection let db: DatabaseConnection = if let Some(db) = self.db { db } else { if let Some(ref url) = self.database_url { // SAFETY: `build()` runs during single-threaded application // initialization, before any worker tasks or background threads // are spawned, so mutating the process environment is sound. unsafe { std::env::set_var("DATABASE_URL", url); } } database::create_connection().await? }; tracing::info!("Database connection established"); // 1b. Auto-migrate if enabled if self.auto_migrate { tracing::info!("Running database migrations..."); crate::migrations::run_migrations(&db) .await .map_err(|e| anyhow::anyhow!("Migration error: {}", e))?; tracing::info!("Database migrations completed"); } // 2. Default admin & settings default_admin_service::ensure_default_admin(&db).await?; AppSettingsService::initialize_default_settings(&db).await?; tracing::info!("Default admin and app settings initialized"); // 3. Session layer — built from the final config (after overrides, // step 0): it reads the cookie / timeout settings. let session_layer = SessionConfig::create_memory_layer(&auth_config); let auth_config = Arc::new(auth_config); let auth_config_for_admin = auth_config.clone(); let auth_config_for_auth = auth_config.clone(); tracing::info!("Session management configured"); // 4. Email & background tasks if self.enable_email && self.enable_background_tasks { Self::start_background_tasks(&db).await; } // 5. JWT cleanup task if self.enable_background_tasks { let db_for_cleanup = db.clone(); tokio::spawn(async move { let secs = crate::config::tasks::jwt_cleanup_interval_secs(); let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(secs)); loop { interval.tick().await; match crate::services::jwt_blacklist_service::JwtBlacklistService::cleanup_expired_tokens(&db_for_cleanup).await { Ok(count) => { if count > 0 { tracing::info!("Cleaned up {} expired JWT tokens from blacklist", count); } } Err(e) => { tracing::error!("Failed to cleanup expired JWT tokens: {}", e); } } } }); tracing::info!("JWT blacklist cleanup task started"); } // 6. I18n let i18n_service = Self::init_i18n(&self.locale_dirs).await; // 6b. OAuth provider registry (if oauth feature enabled) #[cfg(feature = "oauth")] let oauth_registry = { use crate::config::oauth::OAuthConfig; use crate::services::oauth::providers::{ ProviderRegistry, discord::DiscordProvider, github::GitHubProvider, google::GoogleProvider, microsoft::MicrosoftProvider, }; let oauth_config = OAuthConfig::load(&db).await; let mut registry = ProviderRegistry::new(); if oauth_config.enabled { for provider_cfg in oauth_config.enabled_providers() { match provider_cfg.name.as_str() { "google" => { registry.register(Arc::new(GoogleProvider::new( provider_cfg.client_id.clone(), provider_cfg.client_secret.clone(), ))); tracing::info!("OAuth provider registered: Google"); } "github" => { registry.register(Arc::new(GitHubProvider::new( provider_cfg.client_id.clone(), provider_cfg.client_secret.clone(), ))); tracing::info!("OAuth provider registered: GitHub"); } "discord" => { registry.register(Arc::new(DiscordProvider::new( provider_cfg.client_id.clone(), provider_cfg.client_secret.clone(), ))); tracing::info!("OAuth provider registered: Discord"); } "microsoft" => { registry.register(Arc::new(MicrosoftProvider::new( provider_cfg.client_id.clone(), provider_cfg.client_secret.clone(), provider_cfg.tenant.clone(), ))); tracing::info!("OAuth provider registered: Microsoft"); } _ => { tracing::warn!("Unknown OAuth provider: {}", provider_cfg.name); } } } } Arc::new(registry) }; // 7. Build router — split into two families (cf. CSRF plan §4.1): // * browser_router — UI / session-cookie driven, CSRF-protected. // * machine_router — JSON APIs, webhooks, OAuth callbacks, the // ActivityPub inbox: never CSRF-protected. // The CSRF layer is applied to `browser_router` *before* the merge so // it covers the browser family topologically and never leaks onto the // machine family. The shared `session_layer` stays global (below) and // therefore runs *before* the CSRF check, satisfying the `Session` // extractor's prerequisite. let db_for_csrf = db.clone(); let mut browser_router: Router<DatabaseConnection> = Router::new(); if let Some(ui_fn) = self.ui_routes_fn { browser_router = browser_router.merge(ui_fn()); } for custom in self.custom_routes { browser_router = browser_router.merge(custom); } let mut machine_router: Router<DatabaseConnection> = Router::new(); for machine in self.machine_routes { machine_router = machine_router.merge(machine); } // 7b. OAuth routes (conditional on feature + config) — machine family: // JSON API, GETs are safe, the unlink DELETE is Bearer/JSON, never a // browser form submission, so no CSRF token applies. #[cfg(feature = "oauth")] { use crate::auth::oauth_handlers; use axum::routing::get; let oauth_routes = Router::new() .route( "/api/v1/auth/oauth/{provider}/redirect", get(oauth_handlers::oauth_redirect), ) .route( "/api/v1/auth/oauth/{provider}/callback", get(oauth_handlers::oauth_callback), ) .route( "/api/v1/auth/oauth/providers", get(oauth_handlers::list_providers), ) .route( "/api/v1/auth/oauth/accounts", get(oauth_handlers::list_user_oauth_accounts), ) .route( "/api/v1/auth/oauth/accounts/{id}", axum::routing::delete(oauth_handlers::unlink_oauth_account), ); machine_router = machine_router.merge(oauth_routes); } // Federation routes now live in the `rustwarden-fediverse` crate; // consumers mount them themselves via `merge_machine_routes` // (inbox) / `merge_browser_routes` (UI) — cf. `app/src/main.rs`. // Apply CSRF to the browser family only, before merging. Layers // attached to a sub-router are preserved across `Router::merge`, so // the machine family stays free of the CSRF check. let csrf_cfg = Arc::new(CsrfProtectionConfig { accept_form_body: true, max_form_body_bytes: DEFAULT_MAX_FORM_BODY_BYTES, }); let browser_router = browser_router.layer(axum::middleware::from_fn_with_state( (db_for_csrf, csrf_cfg), csrf_protection_with_config, )); let router = browser_router.merge(machine_router); // 8. Apply middleware stack let supported = self.supported_locales.clone(); let default = self.default_locale.clone(); let db_for_admin = db.clone(); let db_for_auth = db.clone(); let db_for_app = db.clone(); // Retained for getter access after build let hooks = self.hooks.clone(); let db_for_ext = db.clone(); let i18n_for_app = i18n_service.clone(); // Retained for getter access after build let auth_config_for_app = auth_config.clone(); // Retained for getter access after build let origins_str = std::env::var("CORS_ALLOWED_ORIGINS").unwrap_or_default(); let cors_layer = if origins_str == "*" { tracing::warn!("CORS_ALLOWED_ORIGINS=* allows any origin. Use only in development."); CorsLayer::new() .allow_origin(Any) .allow_methods([ Method::GET, Method::POST, Method::PUT, Method::DELETE, Method::PATCH, ]) .allow_headers([ axum::http::header::CONTENT_TYPE, axum::http::header::AUTHORIZATION, HeaderName::from_static("x-csrf-token"), ]) .max_age(std::time::Duration::from_secs(3600)) } else if !origins_str.is_empty() { let origins: Vec<axum::http::HeaderValue> = origins_str .split(',') .filter_map(|s| s.trim().parse().ok()) .collect(); if origins.is_empty() { tracing::warn!("CORS_ALLOWED_ORIGINS set but no valid origins parsed"); CorsLayer::new().allow_origin(AllowOrigin::list(std::iter::empty::< axum::http::HeaderValue, >())) } else { tracing::info!("CORS configured for {} origins", origins.len()); CorsLayer::new() .allow_origin(AllowOrigin::list(origins)) .allow_methods([ Method::GET, Method::POST, Method::PUT, Method::DELETE, Method::PATCH, ]) .allow_headers([ axum::http::header::CONTENT_TYPE, axum::http::header::AUTHORIZATION, HeaderName::from_static("x-csrf-token"), ]) .allow_credentials(true) .max_age(std::time::Duration::from_secs(3600)) } } else { CorsLayer::new().allow_origin(AllowOrigin::list(std::iter::empty::< axum::http::HeaderValue, >())) }; let protected_prefixes = std::sync::Arc::new(self.protected_prefixes.clone()); let prefixes_for_auth = std::sync::Arc::clone(&protected_prefixes); let revocation_checker = self.revocation_checker.clone(); let dashboard_cards = self.dashboard_cards.clone(); // RFC 0039: the security-headers middleware is the layer right after // CORS, i.e. *outside* every `Extension(...)` below. Request // extensions are therefore never visible to it on the way in, so the // config is captured in the closure as an `Arc` (same pattern as // `locale_middleware`), NOT injected via `Extension`. // The analytics origin is folded into the CSP *here* rather than in // `RustwardenBuilder::analytics`, so that a later `.security_headers(..)` // call cannot silently drop the allowance. A disabled configuration // with no script URL leaves the policy byte-for-byte untouched. let mut security_headers = self.security_headers.clone(); if let Some(origin) = self.analytics.script_origin() { security_headers = security_headers.allowing_script_origin(&origin); } let headers_cfg = std::sync::Arc::new(security_headers); // Resolve the `app_settings` overrides once, at startup. Failing to // read them must not prevent the application from booting: fall back // to the environment configuration. let analytics_state = match AnalyticsState::load(&db, self.analytics.clone()).await { Ok(state) => std::sync::Arc::new(state), Err(e) => { tracing::warn!( error = %e, "could not read analytics settings; using the environment configuration" ); std::sync::Arc::new(AnalyticsState::from_config(self.analytics.clone())) } }; let app = router.with_state(db).layer( ServiceBuilder::new() .layer(cors_layer) .layer(axum::middleware::from_fn({ let cfg = headers_cfg.clone(); move |req, next| { let cfg = cfg.clone(); async move { security_headers_middleware(cfg, req, next).await } } })) .layer_fn(move |service| { AdminService::with_auth_config( service, db_for_admin.clone(), auth_config_for_admin.clone(), ) }) .layer_fn(move |service| { AuthService::with_auth_config( service, db_for_auth.clone(), prefixes_for_auth.clone(), auth_config_for_auth.clone(), ) }) .layer(session_layer) .layer(axum::middleware::from_fn(move |req, next| { let supported = supported.clone(); let default = default.clone(); locale_middleware(req, next, supported, default) })) .layer(axum::Extension(i18n_service)) .layer(axum::Extension(hooks)) .layer(axum::Extension(auth_config)) .layer(axum::Extension(db_for_ext)) .layer(axum::Extension(analytics_state)) .into_inner(), ); // Wire the revocation checker as an Extension so `AuthService` // can consult it. Registered by `rustwarden-fediverse` via // `RustwardenBuilder::register_revocation_checker`; absent when // the federation crate isn't linked. let app = if let Some(checker) = revocation_checker { app.layer(axum::Extension(checker)) } else { app }; // Expose the configured dashboard cards as an Axum extension so // `admin_dashboard` can render them. Always inserted (even when // the list is empty) so the handler signature stays uniform. let app = app.layer(axum::Extension(dashboard_cards)); #[cfg(feature = "oauth")] let app = app.layer(axum::Extension(oauth_registry)); // 9. Resolve address let address = self .server_address .unwrap_or_else(|| ServerConfig::from_env().address()); tracing::info!("Rustwarden application built successfully"); Ok(RustwardenApp { router: app, address, db: db_for_app, auth_config: auth_config_for_app, i18n: i18n_for_app, }) } async fn start_background_tasks(db: &DatabaseConnection) { let email_config = match EmailConfig::from_env() { Ok(config) => { tracing::info!("Email configuration loaded"); Some(config) } Err(e) => { tracing::warn!("Email not configured: {}. Email features disabled.", e); None } }; if let Some(ref email_cfg) = email_config { use crate::services::email_service::EmailService; use crate::tasks::email_queue_processor::EmailQueueProcessor; let db_arc = Arc::new(db.clone()); match EmailService::new(email_cfg.clone()) { Ok(email_service) => { let email_service_arc = Arc::new(email_service); let processor = EmailQueueProcessor::new( db_arc.clone(), email_service_arc, email_cfg.clone(), ); processor.start(); tracing::info!("Email queue processor started"); } Err(e) => { tracing::error!("Failed to create email service: {}", e); } } { use crate::services::imap_bounce_service::ImapBounceService; let bounce_service = ImapBounceService::new(email_cfg.clone(), db_arc.clone()); if bounce_service.is_enabled() { bounce_service.start(); tracing::info!("IMAP bounce processor started"); } } { use crate::tasks::password_change_email_retry::PasswordChangeEmailRetryProcessor; let retry_processor = PasswordChangeEmailRetryProcessor::new(db_arc.clone(), email_cfg.clone()); retry_processor.start(); tracing::info!("Password change email retry task started"); } } } async fn init_i18n(extra_dirs: &[PathBuf]) -> Arc<I18nService> { let service = I18nService::new(); // Charger d'abord les traductions des crates du framework, // puis les dirs supplementaires (app consommatrice). let mut dirs: Vec<String> = vec![ "crates/rustwarden-core/locales".to_string(), "crates/rustwarden-ui/locales".to_string(), ]; for extra in extra_dirs { dirs.push(extra.to_string_lossy().to_string()); } for dir in &dirs { match load_translations_from_directory(dir).await { Ok(all_translations) => { let mut locale_count = 0; let mut key_count = 0; for (locale, translations) in all_translations { key_count += translations.len(); locale_count += 1; if let Err(e) = service .load_translations(locale.clone(), translations) .await { tracing::error!("Failed to load translations for {}: {}", locale, e); } } tracing::info!( "Loaded {} locales ({} keys) from {}", locale_count, key_count, dir ); } Err(e) => { tracing::warn!("Failed to load translations from {}: {}", dir, e); } } } Arc::new(service) } } /// SEC-H1: Middleware that adds standard security headers to every response. /// /// RFC 0039: the CSP (and opt-in COOP/CORP) come from the /// [`SecurityHeadersConfig`] captured as an `Arc` when the layer is built /// (see `RustwardenBuilder::build`). The config is deliberately NOT read /// from request extensions: this layer sits outside every `Extension(...)` /// layer, so extensions are not populated yet when the request passes here. async fn security_headers_middleware( cfg: Arc<SecurityHeadersConfig>, request: Request, next: Next, ) -> Response { let mut response = next.run(request).await; let headers = response.headers_mut(); headers.insert( HeaderName::from_static("x-content-type-options"), HeaderValue::from_static("nosniff"), ); headers.insert( HeaderName::from_static("x-frame-options"), HeaderValue::from_static("DENY"), ); // Disable legacy XSS auditor (can introduce vulnerabilities) headers.insert( HeaderName::from_static("x-xss-protection"), HeaderValue::from_static("0"), ); headers.insert( HeaderName::from_static("referrer-policy"), HeaderValue::from_static("strict-origin-when-cross-origin"), ); headers.insert( HeaderName::from_static("permissions-policy"), HeaderValue::from_static("camera=(), microphone=(), geolocation=()"), ); // SEC-FIX: CSP header to mitigate XSS attacks. Value comes from the // configured policy; an invalid header value (e.g. control characters) // falls back to the default policy (no 'unsafe-eval') instead of panicking. let csp = HeaderValue::from_str(cfg.content_security_policy.as_ref()).unwrap_or_else(|_| { tracing::warn!( "Invalid Content-Security-Policy value in SecurityHeadersConfig; \ falling back to the default policy" ); HeaderValue::from_static(CSP_WITHOUT_UNSAFE_EVAL) }); headers.insert(HeaderName::from_static("content-security-policy"), csp); // RFC 0039: opt-in cross-origin isolation headers. `None` (the default) // means the header is not emitted at all; a malformed value is skipped // with a warning rather than failing the response. if let Some(coop) = cfg.cross_origin_opener_policy.as_deref() { match HeaderValue::from_str(coop) { Ok(val) => { headers.insert(HeaderName::from_static("cross-origin-opener-policy"), val); } Err(_) => { tracing::warn!( "Invalid Cross-Origin-Opener-Policy value in SecurityHeadersConfig; \ header skipped" ); } } } if let Some(corp) = cfg.cross_origin_resource_policy.as_deref() { match HeaderValue::from_str(corp) { Ok(val) => { headers.insert(HeaderName::from_static("cross-origin-resource-policy"), val); } Err(_) => { tracing::warn!( "Invalid Cross-Origin-Resource-Policy value in SecurityHeadersConfig; \ header skipped" ); } } } // SEC-FIX: HSTS conditioned on APP_DEBUG=false (production mode) instead of COOKIE_SECURE if !std::env::var("APP_DEBUG") .unwrap_or_default() .eq_ignore_ascii_case("true") { headers.insert( HeaderName::from_static("strict-transport-security"), HeaderValue::from_static("max-age=31536000; includeSubDomains"), ); } response } async fn locale_middleware( request: Request, next: Next, supported: Vec<String>, default: String, ) -> Response { let (mut parts, body) = request.into_parts(); let cookie_jar = axum_extra::extract::CookieJar::from_headers(&parts.headers); let locale_str = cookie_jar .get("locale") .and_then(|c| { let v = c.value().to_string(); if supported.contains(&v) { Some(v) } else { None } }) .or_else(|| { parts .headers .get("accept-language") .and_then(|h| h.to_str().ok()) .and_then(|h| { h.split(',') .next() .and_then(|s| s.split(';').next()) .map(|s| s.trim()) .filter(|s| s.len() >= 2) .map(|s| s[..2].to_string()) .filter(|s| supported.contains(s)) }) }) .unwrap_or(default); parts.extensions.insert(Locale(locale_str)); let request = Request::from_parts(parts, body); next.run(request).await } #[cfg(test)] mod builder_tests { use super::*; #[test] fn default_builder_has_no_protected_prefixes() { assert!(RustwardenBuilder::default().protected_prefixes.is_empty()); } #[test] fn with_ui_registers_default_ui_prefixes() { let builder = RustwardenBuilder::default().with_ui(Router::<DatabaseConnection>::new); for prefix in ["/home", "/profile", "/settings", "/admin"] { assert!( builder.protected_prefixes.iter().any(|p| p == prefix), "with_ui must register {prefix}" ); } } #[test] fn headless_sets_flag_without_ui_prefixes() { let builder = RustwardenBuilder::default().headless(); assert!(builder.headless); assert!(builder.protected_prefixes.is_empty()); } #[test] fn register_protected_prefix_appends_custom_prefix() { let builder = RustwardenBuilder::default().register_protected_prefix("/custom"); assert!(builder.protected_prefixes.iter().any(|p| p == "/custom")); } /// RFC 0042 — precedence of the auth overrides, exercised on the pure /// `apply_auth_overrides` without touching the process environment. mod apply_auth_overrides { use super::*; use crate::config::auth::{AuthFailureMode, SessionBackend}; fn base_config() -> AuthConfig { AuthConfig { jwt_secret: "0123456789abcdef0123456789abcdef".to_string(), jwt_expiration_minutes: 15, jwt_issuer: "test".to_string(), refresh_token_expiration_days: 7, remember_me_expiration_days: 30, session_timeout_minutes: 30, session_backend: SessionBackend::Memory, redis_url: None, rate_limit_login_per_minute: 5, rate_limit_refresh_per_minute: 10, rate_limit_general_per_minute: 100, cookie_secure: false, cookie_same_site: "Strict".to_string(), login_path: "/login".to_string(), auth_failure_mode: AuthFailureMode::Redirect, } } #[test] fn headless_flips_default_to_json401() { let cfg = RustwardenBuilder::default() .headless() .apply_auth_overrides(base_config(), false); assert_eq!(cfg.auth_failure_mode, AuthFailureMode::Json401); } #[test] fn explicit_setter_wins_over_headless() { let cfg = RustwardenBuilder::default() .headless() .auth_failure_mode(AuthFailureMode::Redirect) .apply_auth_overrides(base_config(), false); assert_eq!(cfg.auth_failure_mode, AuthFailureMode::Redirect); } #[test] fn env_value_wins_over_headless_flip() { // `base_config()` stands for what `from_env()` produced from an // explicit `AUTH_FAILURE_MODE=redirect`. let cfg = RustwardenBuilder::default() .headless() .apply_auth_overrides(base_config(), true); assert_eq!(cfg.auth_failure_mode, AuthFailureMode::Redirect); } #[test] fn explicit_setter_wins_over_env_value() { let cfg = RustwardenBuilder::default() .auth_failure_mode(AuthFailureMode::Json401) .apply_auth_overrides(base_config(), true); assert_eq!(cfg.auth_failure_mode, AuthFailureMode::Json401); } #[test] fn login_path_setter_is_propagated() { let cfg = RustwardenBuilder::default() .login_path("/auth/signin") .apply_auth_overrides(base_config(), false); assert_eq!(cfg.login_path, "/auth/signin"); } #[test] fn non_headless_without_setter_is_unchanged() { let cfg = RustwardenBuilder::default().apply_auth_overrides(base_config(), false); assert_eq!(cfg.auth_failure_mode, AuthFailureMode::Redirect); assert_eq!(cfg.login_path, "/login"); } /// Only a *parseable* env value counts as an operator override: an /// empty or misspelled `AUTH_FAILURE_MODE` makes `from_env` fall back /// to `Redirect`, and that must not suppress the `headless()` flip. #[test] fn env_signal_requires_a_valid_value() { assert!(!RustwardenBuilder::failure_mode_overridden_by_env(None)); assert!(!RustwardenBuilder::failure_mode_overridden_by_env(Some(""))); assert!(!RustwardenBuilder::failure_mode_overridden_by_env(Some( "json-401" ))); assert!(RustwardenBuilder::failure_mode_overridden_by_env(Some( "json401" ))); assert!(RustwardenBuilder::failure_mode_overridden_by_env(Some( "Redirect" ))); } } #[test] fn default_builder_uses_default_security_headers() { let builder = RustwardenBuilder::default(); assert_eq!( builder.security_headers.content_security_policy, CSP_WITHOUT_UNSAFE_EVAL ); assert!( builder .security_headers .cross_origin_opener_policy .is_none() ); assert!( builder .security_headers .cross_origin_resource_policy .is_none() ); } #[test] fn security_headers_setter_overrides_config() { let builder = RustwardenBuilder::default() .security_headers(SecurityHeadersConfig::default().with_same_origin_isolation()); assert_eq!( builder .security_headers .cross_origin_opener_policy .as_deref(), Some("same-origin") ); } } /// HTTP-level tests for `security_headers_middleware` (RFC 0039 testing /// plan). The middleware is intentionally private to this module, so these /// integration-style tests (axum `Router` + `tower::ServiceExt::oneshot`, /// same pattern as `tests/csrf_protection.rs`) live in the crate's unit /// test tree instead of `tests/`. #[cfg(test)] mod security_headers_middleware_tests { use super::*; use axum::body::Body; use axum::http::Request as HttpRequest; use std::borrow::Cow; use tower::ServiceExt; // for `oneshot` /// Build a minimal router wired exactly like `RustwardenBuilder::build`: /// the config is captured in the `from_fn` closure as an `Arc`. /// /// Negative control for the `Extension` trap (RFC 0039): NO /// `axum::Extension(cfg)` layer is added anywhere here. In production the /// security-headers layer is more external than every `Extension(...)` /// layer, so `request.extensions()` would always come back empty for it — /// the `Arc` capture below is the only supported wiring, and these tests /// prove it works without any extension being injected. fn test_router(cfg: SecurityHeadersConfig) -> Router { let headers_cfg = std::sync::Arc::new(cfg); Router::new() .route("/", axum::routing::get(|| async { "ok" })) .layer(axum::middleware::from_fn({ let cfg = headers_cfg.clone(); move |req, next| { let cfg = cfg.clone(); async move { security_headers_middleware(cfg, req, next).await } } })) } async fn get_root(router: Router) -> Response { let req = HttpRequest::builder() .uri("/") .body(Body::empty()) .expect("request"); router.oneshot(req).await.expect("oneshot") } #[tokio::test] async fn default_config_emits_legacy_csp_and_no_coop_corp() { let resp = get_root(test_router(SecurityHeadersConfig::default())).await; let headers = resp.headers(); assert_eq!( headers .get("content-security-policy") .expect("csp header present"), CSP_WITHOUT_UNSAFE_EVAL ); assert!(headers.get("cross-origin-opener-policy").is_none()); assert!(headers.get("cross-origin-resource-policy").is_none()); } #[tokio::test] async fn same_origin_isolation_emits_all_three_headers() { let cfg = SecurityHeadersConfig::default().with_same_origin_isolation(); let resp = get_root(test_router(cfg)).await; let headers = resp.headers(); assert_eq!( headers .get("content-security-policy") .expect("csp header present"), CSP_WITHOUT_UNSAFE_EVAL ); assert_eq!( headers .get("cross-origin-opener-policy") .expect("coop header present"), "same-origin" ); assert_eq!( headers .get("cross-origin-resource-policy") .expect("corp header present"), "same-origin" ); } #[tokio::test] async fn invalid_csp_falls_back_to_default_without_panicking() { let cfg = SecurityHeadersConfig { // `\n` is a control character: `HeaderValue::from_str` rejects it. content_security_policy: Cow::Owned("default-src 'self'\n".to_string()), ..SecurityHeadersConfig::default() }; let resp = get_root(test_router(cfg)).await; assert_eq!( resp.headers() .get("content-security-policy") .expect("csp header present"), CSP_WITHOUT_UNSAFE_EVAL, "invalid CSP must fall back to the default policy" ); } #[tokio::test] async fn invalid_coop_is_skipped_but_csp_is_still_emitted() { let cfg = SecurityHeadersConfig { cross_origin_opener_policy: Some(Cow::Owned("same-origin\n".to_string())), ..SecurityHeadersConfig::default() }; let resp = get_root(test_router(cfg)).await; let headers = resp.headers(); assert!( headers.get("cross-origin-opener-policy").is_none(), "malformed COOP must be skipped, not emitted" ); assert_eq!( headers .get("content-security-policy") .expect("csp header present"), CSP_WITHOUT_UNSAFE_EVAL ); } /// Regression guard: the hardcoded headers untouched by RFC 0039 must /// keep their 1.0.1 values whatever the configuration. #[tokio::test] async fn hardcoded_headers_are_unchanged() { let resp = get_root(test_router(SecurityHeadersConfig::default())).await; let headers = resp.headers(); assert_eq!( headers .get("x-frame-options") .expect("x-frame-options present"), "DENY" ); assert_eq!( headers .get("x-content-type-options") .expect("x-content-type-options present"), "nosniff" ); assert_eq!( headers .get("x-xss-protection") .expect("x-xss-protection present"), "0" ); assert_eq!( headers .get("referrer-policy") .expect("referrer-policy present"), "strict-origin-when-cross-origin" ); assert_eq!( headers .get("permissions-policy") .expect("permissions-policy present"), "camera=(), microphone=(), geolocation=()" ); } // --- Analytics origin folded into the CSP ------------------------------- /// Mirror of what `RustwardenBuilder::build` does with the analytics /// configuration, so these tests exercise the real composition rule. fn headers_for(analytics: &AnalyticsConfig, base: SecurityHeadersConfig) -> String { let cfg = match analytics.script_origin() { Some(origin) => base.allowing_script_origin(&origin), None => base, }; cfg.content_security_policy.to_string() } #[tokio::test] async fn analytics_disabled_leaves_the_csp_byte_identical() { // A consumer that never configures analytics must see the default // policy (RFC 0039 phase 2: no 'unsafe-eval'), unchanged. let csp = headers_for( &AnalyticsConfig::default(), SecurityHeadersConfig::default(), ); let cfg = SecurityHeadersConfig { content_security_policy: Cow::Owned(csp), ..SecurityHeadersConfig::default() }; let resp = get_root(test_router(cfg)).await; assert_eq!( resp.headers() .get("content-security-policy") .expect("csp header present"), CSP_WITHOUT_UNSAFE_EVAL ); } #[tokio::test] async fn analytics_origin_reaches_the_emitted_header() { let analytics = AnalyticsConfig::default() .with_script_url("https://stats.example.org/api/script.js") .with_site_id("abc123") .enabled(true); let csp = headers_for(&analytics, SecurityHeadersConfig::default()); let cfg = SecurityHeadersConfig { content_security_policy: Cow::Owned(csp), ..SecurityHeadersConfig::default() }; let resp = get_root(test_router(cfg)).await; let header = resp .headers() .get("content-security-policy") .and_then(|v| v.to_str().ok()) .unwrap_or_default() .to_string(); let directive = |name: &str| -> String { header .split(';') .map(str::trim) .find(|d| d.split_whitespace().next() == Some(name)) .unwrap_or_default() .to_string() }; assert!( directive("script-src").contains("https://stats.example.org"), "script-src must allow the tracker: {}", header ); assert!( directive("connect-src").contains("https://stats.example.org"), "connect-src must allow the beacons: {}", header ); // Only the path is dropped — the origin keeps scheme and host. assert!(!header.contains("/api/script.js")); } #[tokio::test] async fn analytics_origin_is_allowed_even_while_tracking_is_off() { // The runtime toggle lives in the database and may be flipped on // without a restart, so the CSP must already allow the origin. let analytics = AnalyticsConfig::default() .with_script_url("https://stats.example.org/api/script.js") .enabled(false); let csp = headers_for(&analytics, SecurityHeadersConfig::default()); assert!(csp.contains("https://stats.example.org")); } }
GitRust