arcly-http 0.2.0

Enterprise-grade NestJS-inspired web framework on axum: zero-lock DI, declarative controllers, multi-tenant data routing, transactional outbox, ABAC, and a self-documenting OpenAPI surface
Documentation
//! Security headers middleware -- a Helmet.js equivalent for arcly-http.
//!
//! ## Quick start
//!
//! The default configuration applies the recommended OWASP header set
//! automatically. To customise, call [`configure`] once at server startup
//! (before `App::launch`):
//!
//! ```rust,ignore
//! use arcly_http::security::{configure, SecurityConfig, FrameOptions};
//!
//! security::configure(SecurityConfig {
//!     hsts_max_age: 0,                           // disable HSTS in dev
//!     frame_options: FrameOptions::SameOrigin,
//!     ..SecurityConfig::default()
//! });
//! ```
//!
//! ## Headers emitted (defaults)
//!
//! | Header                      | Default value                            |
//! |-----------------------------|------------------------------------------|
//! | `Strict-Transport-Security` | `max-age=31536000; includeSubDomains; preload` |
//! | `X-Frame-Options`           | `DENY`                                   |
//! | `X-Content-Type-Options`    | `nosniff`                                |
//! | `X-XSS-Protection`          | `1; mode=block`                          |
//! | `Referrer-Policy`           | `strict-origin-when-cross-origin`        |
//! | `Permissions-Policy`        | restrictive: camera, mic, geo, payment   |
//! | `Content-Security-Policy`   | `default-src 'self'` (relaxed for /docs) |

use std::sync::OnceLock;

use axum::{extract::Request, http::HeaderValue, middleware::Next, response::Response};

// ─── Configuration types ─────────────────────────────────────────────────────

/// Controls what `X-Frame-Options` header is emitted.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FrameOptions {
    /// `DENY` -- recommended for most APIs.
    Deny,
    /// `SAMEORIGIN` -- allows embedding in same-origin frames.
    SameOrigin,
    /// Omit the header entirely (e.g. for public media CDNs).
    Disabled,
}

/// Full security header configuration.
///
/// Every field has a production-safe default. Override only what your
/// deployment requires. See module docs for the default value table.
#[derive(Debug, Clone)]
pub struct SecurityConfig {
    /// HSTS `max-age` in seconds. Set `0` to disable the header (e.g. in local
    /// dev where TLS is not in use). Default: `31_536_000` (1 year).
    pub hsts_max_age: u64,
    /// Include `includeSubDomains` directive. Default: `true`.
    pub hsts_include_subdomains: bool,
    /// Include `preload` directive (required for HSTS preload submission).
    /// Default: `true`.
    pub hsts_preload: bool,
    /// `X-Frame-Options` policy. Default: [`FrameOptions::Deny`].
    pub frame_options: FrameOptions,
    /// `Referrer-Policy` value. Must be a `'static` str.
    /// Default: `"strict-origin-when-cross-origin"`.
    pub referrer_policy: &'static str,
    /// `Permissions-Policy` value. `None` omits the header.
    /// Default: restrictive policy disabling camera, microphone, geolocation,
    /// payment, and USB.
    pub permissions_policy: Option<&'static str>,
    /// CSP for all routes except `/docs`. Default: `"default-src 'self'"`.
    pub csp_default: &'static str,
    /// CSP override for the `/docs` Swagger UI route. `None` uses `csp_default`.
    /// Default: relaxes `script-src` and `style-src` for the unpkg CDN.
    pub csp_docs_override: Option<&'static str>,
    /// Enable `X-Content-Type-Options: nosniff`. Default: `true`.
    pub nosniff: bool,
    /// Enable `X-XSS-Protection: 1; mode=block`. Default: `true`.
    pub xss_protection: bool,
}

impl Default for SecurityConfig {
    fn default() -> Self {
        Self {
            hsts_max_age: 31_536_000,
            hsts_include_subdomains: true,
            hsts_preload: true,
            frame_options: FrameOptions::Deny,
            referrer_policy: "strict-origin-when-cross-origin",
            permissions_policy: Some(
                "camera=(), microphone=(), geolocation=(), payment=(), usb=()",
            ),
            csp_default: "default-src 'self'",
            csp_docs_override: Some(concat!(
                "default-src 'self'; ",
                "script-src 'self' 'unsafe-inline' https://unpkg.com; ",
                "style-src 'self' 'unsafe-inline' https://unpkg.com; ",
                "img-src 'self' data: https://unpkg.com; ",
                "font-src 'self' data: https://unpkg.com; ",
                "connect-src 'self'",
            )),
            nosniff: true,
            xss_protection: true,
        }
    }
}

// ─── Global config store ─────────────────────────────────────────────────────

static CONFIG: OnceLock<SecurityConfig> = OnceLock::new();

/// Install a custom security configuration.
///
/// **Must be called before `App::launch`**. The first call wins; subsequent
/// calls are silently ignored (safe to call from multiple threads at startup).
pub fn configure(cfg: SecurityConfig) {
    let _ = CONFIG.set(cfg);
}

#[inline]
fn cfg() -> &'static SecurityConfig {
    CONFIG.get_or_init(SecurityConfig::default)
}

// ─── Axum middleware ─────────────────────────────────────────────────────────

/// Apply all configured security headers to every response.
///
/// Registered automatically by `App::launch` as the outermost layer.
pub async fn apply_security_headers(req: Request, next: Next) -> Response {
    let is_docs = req.uri().path() == "/docs";
    let mut resp = next.run(req).await;
    let c = cfg();
    let h = resp.headers_mut();

    // Strict-Transport-Security
    if c.hsts_max_age > 0 {
        let mut val = format!("max-age={}", c.hsts_max_age);
        if c.hsts_include_subdomains {
            val.push_str("; includeSubDomains");
        }
        if c.hsts_preload {
            val.push_str("; preload");
        }
        if let Ok(v) = HeaderValue::from_str(&val) {
            h.insert("Strict-Transport-Security", v);
        }
    }

    // X-Frame-Options
    match c.frame_options {
        FrameOptions::Deny => {
            h.insert("X-Frame-Options", HeaderValue::from_static("DENY"));
        }
        FrameOptions::SameOrigin => {
            h.insert("X-Frame-Options", HeaderValue::from_static("SAMEORIGIN"));
        }
        FrameOptions::Disabled => {}
    }

    if c.nosniff {
        h.insert(
            "X-Content-Type-Options",
            HeaderValue::from_static("nosniff"),
        );
    }
    if c.xss_protection {
        h.insert(
            "X-XSS-Protection",
            HeaderValue::from_static("1; mode=block"),
        );
    }
    h.insert(
        "Referrer-Policy",
        HeaderValue::from_static(c.referrer_policy),
    );

    if let Some(pp) = c.permissions_policy {
        h.insert("Permissions-Policy", HeaderValue::from_static(pp));
    }

    let csp = if is_docs {
        c.csp_docs_override.unwrap_or(c.csp_default)
    } else {
        c.csp_default
    };
    h.insert("Content-Security-Policy", HeaderValue::from_static(csp));

    resp
}