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);
    }
}