runique 3.0.3

A Django-inspired web framework for Rust with ORM, templates, and comprehensive security middleware
Documentation
//! Content Security Policy Middleware: generates CSP headers with nonce per request.
use crate::context::RequestExtensions;
use crate::utils::{aliases::AEngine, csp_nonce::CspNonce};

/// Hashes of inline styles injected by htmx (embedded version: 2.0.4).
///
/// These hashes are deterministic: SHA-256 of the exact injected style value
/// (e.g., `display:none`). They do not change for a given value, but must
/// be updated if the htmx version changes and injects different values.
///
/// Reference: `runique/templates/admin/composant/list.html`
/// → `https://unpkg.com/htmx.org@2.0.4/dist/htmx.min.js`
///
/// To add a missing hash: the browser indicates it in the CSP console.
pub const HTMX_STYLE_HASHES: &[&str] = &["'sha256-bsV5JivYxvGywDAZ22EZJKBFip65Ng9xoJVLbBg7bdo='"];
use axum::{
    body::Body,
    extract::State,
    http::{HeaderValue, Request},
    middleware::Next,
    response::{IntoResponse, Redirect, Response},
};
use serde::{Deserialize, Serialize};

/// Content-Security-Policy directive set rendered into the
/// `Content-Security-Policy` header. Use `SecurityPolicy::default()` for the
/// framework's baseline policy, `strict()` for a tighter preset, or
/// `permissive()` for local development.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct SecurityPolicy {
    pub default_src: Vec<String>,
    pub script_src: Vec<String>,
    pub style_src: Vec<String>,
    pub img_src: Vec<String>,
    pub font_src: Vec<String>,
    pub connect_src: Vec<String>,
    /// Authorized sources for embedded objects (plugins, applets)
    pub object_src: Vec<String>,
    /// Authorized sources for audio/video media
    pub media_src: Vec<String>,
    /// Authorized sources for iframes
    pub frame_src: Vec<String>,
    pub frame_ancestors: Vec<String>,
    pub base_uri: Vec<String>,
    pub form_action: Vec<String>,
    /// Adds the `upgrade-insecure-requests` directive when `true`.
    pub upgrade_insecure_requests: bool,
}

impl Default for SecurityPolicy {
    fn default() -> Self {
        Self {
            default_src: vec!["'none'".into()],
            script_src: vec!["'self'".into()],
            // 'unsafe-inline' required for libraries like htmx that inject
            // inline styles (e.g., style="display:none") without nonce.
            style_src: vec!["'self'".into(), "'unsafe-inline'".into()],
            // Only `'self'` by default.
            // To allow inline base64 images (avatars, rich-text editors),
            // add `data:` via the env variable:
            //   RUNIQUE_POLICY_CSP_IMAGES='self',data:
            img_src: vec!["'self'".into()],
            font_src: vec!["'self'".into()],
            connect_src: vec!["'self'".into()],
            // Block all embedded objects by default
            object_src: vec!["'none'".into()],
            // Allow media from the same domain
            media_src: vec!["'self'".into()],
            // Block iframes by default
            frame_src: vec!["'none'".into()],
            frame_ancestors: vec!["'none'".into()],
            base_uri: vec!["'self'".into()],
            form_action: vec!["'self'".into()],
            upgrade_insecure_requests: false,
        }
    }
}

impl SecurityPolicy {
    /// A tighter preset: no `'unsafe-inline'` anywhere and
    /// `upgrade-insecure-requests` enabled. Note this drops the
    /// `'unsafe-inline'` style-src fallback htmx relies on for its injected
    /// inline styles — pair with `merge_htmx_hashes()` if htmx is in use.
    pub fn strict() -> Self {
        Self {
            default_src: vec!["'none'".into()],
            script_src: vec!["'self'".into()],
            style_src: vec!["'self'".into()],
            img_src: vec!["'self'".into()],
            font_src: vec!["'self'".into()],
            connect_src: vec!["'self'".into()],
            object_src: vec!["'none'".into()],
            media_src: vec!["'self'".into()],
            frame_src: vec!["'none'".into()],
            frame_ancestors: vec!["'none'".into()],
            base_uri: vec!["'self'".into()],
            form_action: vec!["'self'".into()],
            upgrade_insecure_requests: true,
        }
    }
    /// A looser preset for local development: allows `'unsafe-inline'` and
    /// `'unsafe-eval'` in `script-src` and `https:`/`data:` in a few source
    /// lists. Not intended for production.
    pub fn permissive() -> Self {
        Self {
            default_src: vec!["'none'".into()],
            script_src: vec![
                "'self'".into(),
                "'unsafe-inline'".into(),
                "'unsafe-eval'".into(),
            ],
            style_src: vec!["'self'".into(), "'unsafe-inline'".into()],
            img_src: vec!["'self'".into(), "data:".into(), "https:".into()],
            font_src: vec!["'self'".into(), "data:".into()],
            connect_src: vec!["'self'".into()],
            object_src: vec!["'self'".into()],
            media_src: vec!["'self'".into(), "https:".into()],
            frame_src: vec!["'self'".into()],
            frame_ancestors: vec!["'self'".into()],
            base_uri: vec!["'self'".into()],
            form_action: vec!["'self'".into()],
            upgrade_insecure_requests: false,
        }
    }

    /// Adds known htmx inline style hashes to `style_src`.
    ///
    /// Called automatically by the builder when `.with_admin()` is activated.
    /// Avoids opening `'unsafe-inline'` on `style-src` while allowing
    /// dynamically injected styles by htmx.
    pub fn merge_htmx_hashes(&mut self) {
        for hash in HTMX_STYLE_HASHES {
            let s = hash.to_string();
            if !self.style_src.contains(&s) {
                self.style_src.push(s);
            }
        }
    }
    /// Renders this policy into a `Content-Security-Policy` header value,
    /// joining non-empty directives with `; `. Whenever a non-empty `nonce`
    /// is given, it is appended to `script-src`/`style-src` and
    /// `'unsafe-inline'` — and, for styles, `'unsafe-hashes'` — is stripped
    /// from those directives.
    #[must_use]
    pub fn to_header_value(&self, nonce: Option<&str>) -> String {
        let mut directives = Vec::new();

        if !self.default_src.is_empty() {
            directives.push(format!("default-src {}", self.default_src.join(" ")));
        }

        if !self.script_src.is_empty() {
            let mut script_sources = self.script_src.clone();
            if let Some(n) = nonce.filter(|n| !n.is_empty()) {
                script_sources.push(format!("'nonce-{n}'"));
                script_sources.retain(|s| s != "'unsafe-inline'");
            }
            directives.push(format!("script-src {}", script_sources.join(" ")));
        }

        if !self.style_src.is_empty() {
            let mut style_sources = self.style_src.clone();
            if let Some(n) = nonce.filter(|n| !n.is_empty()) {
                style_sources.push(format!("'nonce-{n}'"));
                style_sources.retain(|s| s != "'unsafe-inline'" && s != "'unsafe-hashes'");
                // ↑ Also remove 'unsafe-hashes' when nonce is present
            }
            directives.push(format!("style-src {}", style_sources.join(" ")));
        }

        if !self.img_src.is_empty() {
            directives.push(format!("img-src {}", self.img_src.join(" ")));
        }
        if !self.font_src.is_empty() {
            directives.push(format!("font-src {}", self.font_src.join(" ")));
        }
        if !self.connect_src.is_empty() {
            directives.push(format!("connect-src {}", self.connect_src.join(" ")));
        }
        if !self.object_src.is_empty() {
            directives.push(format!("object-src {}", self.object_src.join(" ")));
        }
        if !self.media_src.is_empty() {
            directives.push(format!("media-src {}", self.media_src.join(" ")));
        }
        if !self.frame_src.is_empty() {
            directives.push(format!("frame-src {}", self.frame_src.join(" ")));
        }
        if !self.frame_ancestors.is_empty() {
            directives.push(format!(
                "frame-ancestors {}",
                self.frame_ancestors.join(" ")
            ));
        }
        if !self.base_uri.is_empty() {
            directives.push(format!("base-uri {}", self.base_uri.join(" ")));
        }
        if !self.form_action.is_empty() {
            directives.push(format!("form-action {}", self.form_action.join(" ")));
        }

        if self.upgrade_insecure_requests {
            directives.push("upgrade-insecure-requests".to_string());
        }

        directives.join("; ")
    }
}

/// Global security middleware (CSP + miscellaneous headers)
pub async fn security_headers_middleware(
    State(engine): State<AEngine>,
    mut req: Request<Body>,
    next: Next,
) -> Response {
    // Generate a unique nonce for this request
    let nonce = CspNonce::generate();

    // Injection via centralized structure
    let extensions = RequestExtensions::new().with_csp_nonce(nonce.clone());

    extensions.inject_request(&mut req);

    let mut response = next.run(req).await;
    let headers = response.headers_mut();

    // Use the nonce to build the CSP
    let csp_value = engine.security_csp.to_header_value(Some(nonce.as_str()));
    if let Ok(header) = HeaderValue::from_str(&csp_value) {
        headers.insert(axum::http::header::CONTENT_SECURITY_POLICY, header);
    }

    // Other security headers
    headers.insert(
        axum::http::header::X_CONTENT_TYPE_OPTIONS,
        HeaderValue::from_static("nosniff"),
    );

    headers.insert(
        axum::http::header::X_FRAME_OPTIONS,
        HeaderValue::from_static("DENY"),
    );

    headers.insert(
        "x-xss-protection",
        HeaderValue::from_static("1; mode=block"),
    );

    headers.insert(
        axum::http::header::REFERRER_POLICY,
        HeaderValue::from_static("strict-origin-when-cross-origin"),
    );

    let pp = engine.permissions_policy.to_header_value();
    if let Ok(v) = HeaderValue::from_str(&pp) {
        headers.insert("permissions-policy", v);
    }

    headers.insert(
        "cross-origin-embedder-policy",
        HeaderValue::from_static("require-corp"),
    );

    headers.insert(
        "cross-origin-opener-policy",
        HeaderValue::from_static("same-origin"),
    );

    headers.insert(
        "cross-origin-resource-policy",
        HeaderValue::from_static("same-origin"),
    );

    // HSTS uniquement si Runique sert réellement du HTTPS (ACME / enforce_https) :
    // l'émettre en HTTP simple est inutile (ignoré) et risqué (lock-in HTTPS d'un an).
    // Valeur configurable (max-age/includeSubDomains/preload) via `hsts_header_value`.
    if let Some(hsts) = engine.config.security.hsts_header_value()
        && let Ok(val) = HeaderValue::from_str(&hsts)
    {
        headers.insert("strict-transport-security", val);
    }

    response
}

/// HTTPS redirection behind a TLS-terminating proxy (`ENFORCE_HTTPS`): a
/// request the proxy received over HTTP gets a 308 to the same URL in HTTPS.
/// Not mounted when ACME is active: Runique then serves TLS itself and its
/// port-80 listener already redirects.
pub async fn https_redirect_middleware(
    State(engine): State<AEngine>,
    req: Request<Body>,
    next: Next,
) -> Response {
    // Check if enforce_https is enabled
    if !engine.config.security.enforce_https {
        return next.run(req).await;
    }

    // Only a proxy saying `X-Forwarded-Proto: http` triggers the redirect. A
    // request without the header can't be told apart from one a proxy forwarded
    // over HTTPS without saying so: redirecting it would loop forever.
    // The header is trusted unconditionally on purpose — gating it on a trusted
    // peer would break public-IP proxies (e.g. Cloudflare) into a redirect loop,
    // and forging it over plain HTTP only affects the attacker's own connection.
    // With several proxies the first value is the client-facing one.
    let forwarded_over_http = req
        .headers()
        .get("x-forwarded-proto")
        .and_then(|v| v.to_str().ok())
        .and_then(|v| v.split(',').next())
        .is_some_and(|proto| proto.trim().eq_ignore_ascii_case("http"));

    if !forwarded_over_http {
        return next.run(req).await;
    }

    // Build the HTTPS URL
    let host = req
        .headers()
        .get(axum::http::header::HOST)
        .and_then(|v| v.to_str().ok())
        .or_else(|| req.uri().authority().map(|a| a.as_str()))
        .unwrap_or("localhost");

    let uri = req.uri();
    let https_url = format!(
        "https://{}{}",
        host,
        uri.path_and_query().map_or("", |pq| pq.as_str())
    );

    Redirect::permanent(&https_url).into_response()
}