security_headers.rs
311 lignes · 12601 octets
//! Security response headers configuration (RFC 0039). //! //! Additive configuration for the security headers middleware wired by //! `RustwardenApp::builder()`: a configurable `Content-Security-Policy` //! plus opt-in `Cross-Origin-Opener-Policy` / `Cross-Origin-Resource-Policy`. //! //! The [`Default`] implementation ships the phase-2 CSP: `'unsafe-inline'` //! stays (inline boot scripts and HTMX), `'unsafe-eval'` is gone because the //! reference UI loads Alpine's CSP build. The historical 1.0.1 policy remains //! available as [`DEFAULT_CONTENT_SECURITY_POLICY`] for consumers that still //! need `eval`. COOP/CORP are not emitted at all. //! //! The other hardcoded headers (`X-Content-Type-Options`, `X-Frame-Options`, //! `X-XSS-Protection`, `Referrer-Policy`, `Permissions-Policy`, HSTS) are //! intentionally **not** configurable here (RFC 0039 non-goals). use std::borrow::Cow; /// Historical CSP shipped in 1.0.1, including `'unsafe-eval'`. /// /// Kept so a consumer that still evaluates Alpine expressions with the stock /// build can opt back in: /// `SecurityHeadersConfig { content_security_policy: Cow::Borrowed(DEFAULT_CONTENT_SECURITY_POLICY), ..SecurityHeadersConfig::default() }`. pub const DEFAULT_CONTENT_SECURITY_POLICY: &str = "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'"; /// Default CSP since RFC 0039 phase 2 (Alpine CSP build, no `'unsafe-eval'`). /// `'unsafe-inline'` remains until a later CSP phase. pub const CSP_WITHOUT_UNSAFE_EVAL: &str = "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'"; /// Configurable security headers applied by the framework's security /// headers middleware (see `RustwardenBuilder::security_headers`). /// /// # Examples /// /// ``` /// use rustwarden_core::config::security_headers::{ /// CSP_WITHOUT_UNSAFE_EVAL, SecurityHeadersConfig, /// }; /// /// // The default CSP has no 'unsafe-eval'. COOP and CORP stay opt-in. /// let cfg = SecurityHeadersConfig::default(); /// assert_eq!(cfg.content_security_policy, CSP_WITHOUT_UNSAFE_EVAL); /// assert!(cfg.cross_origin_opener_policy.is_none()); /// assert!(cfg.cross_origin_resource_policy.is_none()); /// /// // Opt-in cross-origin isolation (e.g. Gitrust): /// let isolated = SecurityHeadersConfig::default().with_same_origin_isolation(); /// assert_eq!(isolated.cross_origin_opener_policy.as_deref(), Some("same-origin")); /// assert_eq!(isolated.cross_origin_resource_policy.as_deref(), Some("same-origin")); /// ``` #[derive(Clone, Debug)] pub struct SecurityHeadersConfig { /// `Content-Security-Policy` value. Default is [`CSP_WITHOUT_UNSAFE_EVAL`]. pub content_security_policy: Cow<'static, str>, /// `Cross-Origin-Opener-Policy`. `None` = do not set the header (opt-in). pub cross_origin_opener_policy: Option<Cow<'static, str>>, /// `Cross-Origin-Resource-Policy`. `None` = do not set the header (opt-in). pub cross_origin_resource_policy: Option<Cow<'static, str>>, } impl Default for SecurityHeadersConfig { fn default() -> Self { Self { content_security_policy: Cow::Borrowed(CSP_WITHOUT_UNSAFE_EVAL), cross_origin_opener_policy: None, cross_origin_resource_policy: None, } } } impl SecurityHeadersConfig { /// Opt-in isolation headers commonly used by Gitrust: sets both /// `Cross-Origin-Opener-Policy` and `Cross-Origin-Resource-Policy` /// to `same-origin`. pub fn with_same_origin_isolation(mut self) -> Self { self.cross_origin_opener_policy = Some(Cow::Borrowed("same-origin")); self.cross_origin_resource_policy = Some(Cow::Borrowed("same-origin")); self } /// Allow `origin` to serve scripts and receive beacons, by adding it to /// both the `script-src` and `connect-src` directives of the CSP. /// /// A third-party analytics script needs both: the browser fetches the /// script from that origin, then the script posts events back to it. /// /// The operation is idempotent — an origin already present is not /// duplicated — and leaves every other directive untouched. A target /// directive absent from the policy is created from the sources of /// `default-src`, mirroring the fallback the browser itself applies. /// /// Wired automatically by `RustwardenBuilder::build` from /// [`crate::config::analytics::AnalyticsConfig::script_origin`]; consumers /// rarely need to call it directly. /// /// # Examples /// /// ``` /// use rustwarden_core::config::security_headers::SecurityHeadersConfig; /// /// let cfg = SecurityHeadersConfig::default() /// .allowing_script_origin("https://stats.example.org"); /// /// assert!(cfg.content_security_policy.contains( /// "script-src 'self' 'unsafe-inline' https://stats.example.org" /// )); /// assert!(cfg.content_security_policy.contains( /// "connect-src 'self' https://stats.example.org" /// )); /// ``` pub fn allowing_script_origin(mut self, origin: &str) -> Self { let origin = origin.trim(); if origin.is_empty() { return self; } let policy = add_source_to_directives( &self.content_security_policy, origin, &["script-src", "connect-src"], ); self.content_security_policy = Cow::Owned(policy); self } } /// Add `source` to each of `directives` in a CSP string. /// /// Existing directives keep their original position and source order; a /// missing directive is appended, seeded with the sources of `default-src`. fn add_source_to_directives(policy: &str, source: &str, directives: &[&str]) -> String { let mut parsed: Vec<(String, Vec<String>)> = policy .split(';') .map(str::trim) .filter(|d| !d.is_empty()) .filter_map(|directive| { let mut tokens = directive.split_whitespace(); let name = tokens.next()?.to_string(); Some((name, tokens.map(str::to_string).collect())) }) .collect(); let default_sources: Vec<String> = parsed .iter() .find(|(name, _)| name == "default-src") .map(|(_, sources)| sources.clone()) .unwrap_or_default(); for target in directives { match parsed.iter_mut().find(|(name, _)| name == target) { Some((_, sources)) => { if !sources.iter().any(|s| s == source) { sources.push(source.to_string()); } } None => { let mut sources = default_sources.clone(); if !sources.iter().any(|s| s == source) { sources.push(source.to_string()); } parsed.push(((*target).to_string(), sources)); } } } parsed .into_iter() .map(|(name, sources)| { if sources.is_empty() { name } else { format!("{} {}", name, sources.join(" ")) } }) .collect::<Vec<_>>() .join("; ") } #[cfg(test)] mod tests { use super::*; /// Phase 2 (RFC 0039): the default policy is the CSP build, without /// `'unsafe-eval'`. The historical 1.0.1 literal stays on the constant /// so consumers can opt back in. #[test] fn default_csp_omits_unsafe_eval() { let cfg = SecurityHeadersConfig::default(); assert_eq!(cfg.content_security_policy, CSP_WITHOUT_UNSAFE_EVAL); assert!(!cfg.content_security_policy.contains("unsafe-eval")); assert_eq!( DEFAULT_CONTENT_SECURITY_POLICY, "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'" ); } #[test] fn default_does_not_opt_into_coop_corp() { let cfg = SecurityHeadersConfig::default(); assert!(cfg.cross_origin_opener_policy.is_none()); assert!(cfg.cross_origin_resource_policy.is_none()); } #[test] fn with_same_origin_isolation_sets_both_headers() { let cfg = SecurityHeadersConfig::default().with_same_origin_isolation(); assert_eq!( cfg.cross_origin_opener_policy.as_deref(), Some("same-origin") ); assert_eq!( cfg.cross_origin_resource_policy.as_deref(), Some("same-origin") ); // The CSP is untouched by the isolation helper. assert_eq!(cfg.content_security_policy, CSP_WITHOUT_UNSAFE_EVAL); } /// The Phase 2 candidate constant only differs from the default by the /// removal of `'unsafe-eval'` — keep them from drifting apart. #[test] fn phase2_candidate_is_default_without_unsafe_eval() { assert_eq!( DEFAULT_CONTENT_SECURITY_POLICY.replace(" 'unsafe-eval'", ""), CSP_WITHOUT_UNSAFE_EVAL ); } // --- allowing_script_origin (analytics) --------------------------------- /// Extract the sources of one directive, for assertions. fn sources_of<'a>(policy: &'a str, directive: &str) -> Vec<&'a str> { policy .split(';') .map(str::trim) .find(|d| d.split_whitespace().next() == Some(directive)) .map(|d| d.split_whitespace().skip(1).collect()) .unwrap_or_default() } #[test] fn allowing_script_origin_adds_to_script_and_connect() { let cfg = SecurityHeadersConfig::default().allowing_script_origin("https://stats.example.org"); let policy = &cfg.content_security_policy; assert!(sources_of(policy, "script-src").contains(&"https://stats.example.org")); assert!(sources_of(policy, "connect-src").contains(&"https://stats.example.org")); } #[test] fn allowing_script_origin_preserves_other_directives() { let cfg = SecurityHeadersConfig::default().allowing_script_origin("https://stats.example.org"); let policy = &cfg.content_security_policy; assert_eq!(sources_of(policy, "default-src"), vec!["'self'"]); assert_eq!(sources_of(policy, "frame-ancestors"), vec!["'none'"]); assert_eq!(sources_of(policy, "img-src"), vec!["'self'", "data:"]); assert_eq!(sources_of(policy, "form-action"), vec!["'self'"]); // Existing script-src sources are kept, the origin is appended. assert_eq!( sources_of(policy, "script-src"), vec!["'self'", "'unsafe-inline'", "https://stats.example.org"] ); } #[test] fn allowing_script_origin_is_idempotent() { let once = SecurityHeadersConfig::default().allowing_script_origin("https://stats.example.org"); let twice = once .clone() .allowing_script_origin("https://stats.example.org"); assert_eq!(once.content_security_policy, twice.content_security_policy); } #[test] fn allowing_script_origin_creates_missing_directive_from_default_src() { let cfg = SecurityHeadersConfig { content_security_policy: Cow::Borrowed("default-src 'self' https://cdn.example.org"), ..SecurityHeadersConfig::default() } .allowing_script_origin("https://stats.example.org"); let policy = &cfg.content_security_policy; // The browser would fall back to default-src, so the created // directive must not silently drop its sources. assert_eq!( sources_of(policy, "script-src"), vec![ "'self'", "https://cdn.example.org", "https://stats.example.org" ] ); assert_eq!( sources_of(policy, "connect-src"), vec![ "'self'", "https://cdn.example.org", "https://stats.example.org" ] ); } #[test] fn allowing_empty_origin_is_a_noop() { let cfg = SecurityHeadersConfig::default().allowing_script_origin(" "); assert_eq!(cfg.content_security_policy, CSP_WITHOUT_UNSAFE_EVAL); } }
GitRust