ullun-api 0.4.10

Express.js-inspired web framework for Rust - simple, fast, batteries-included
Documentation
//! Security response headers, including a Content-Security-Policy builder
//!
//! [`SecurityHeaders`] adds the headers every response should carry. A header
//! the handler already set is left alone, so one route can loosen the policy.
//!
//! ```no_run
//! use ullun::prelude::*;
//! use ullun::security_headers::{Csp, SecurityHeaders};
//!
//! # async fn run() {
//! App::new()
//!     .middleware(SecurityHeaders::new().csp(Csp::web().script_src(["'self'", "'nonce-{nonce}'"])))
//!     .get("/", |req: Request| async move {
//!         // the nonce is fresh for every request
//!         let nonce = req.csp_nonce().unwrap_or_default().to_string();
//!         Ok(Response::html(format!("<script nonce=\"{nonce}\">console.log(1)</script>")))
//!     })
//!     .run("127.0.0.1:3000")
//!     .await
//!     .unwrap();
//! # }
//! ```
//!
//! Defaults of [`SecurityHeaders::new`]: `X-Content-Type-Options: nosniff`,
//! `X-Frame-Options: DENY`, `Referrer-Policy: no-referrer`,
//! `Cross-Origin-Opener-Policy: same-origin`,
//! `Cross-Origin-Resource-Policy: same-origin` and a `Permissions-Policy` that
//! switches off camera, microphone, geolocation and payment. HSTS is a separate
//! middleware ([`crate::secure::Hsts`]) because it only belongs on HTTPS.

use crate::error::Result;
use crate::middleware::{Middleware, Next};
use crate::request::Request;
use crate::response::Response;
use async_trait::async_trait;

/// The per-request nonce generated for a policy that uses the `{nonce}` placeholder
#[derive(Clone, Debug)]
pub(crate) struct CspNonce(pub(crate) String);

/// A Content-Security-Policy under construction
///
/// A value of `'nonce-{nonce}'` in any directive is replaced with a fresh random
/// nonce per request, readable through [`Request::csp_nonce`] for inline
/// `<script nonce="...">` tags.
#[derive(Clone, Debug, Default)]
pub struct Csp {
    directives: Vec<(String, Vec<String>)>,
    report_only: bool,
}

impl Csp {
    /// Empty policy
    pub fn new() -> Self {
        Self::default()
    }

    /// For JSON APIs: nothing may load or embed anything
    pub fn api() -> Self {
        Self::new()
            .directive("default-src", ["'none'"])
            .directive("frame-ancestors", ["'none'"])
    }

    /// For server-rendered pages: same-origin resources only, no plugins, no
    /// framing, forms post to the same origin
    pub fn web() -> Self {
        Self::new()
            .directive("default-src", ["'self'"])
            .directive("base-uri", ["'self'"])
            .directive("form-action", ["'self'"])
            .directive("frame-ancestors", ["'none'"])
            .directive("object-src", ["'none'"])
    }

    /// Set (replace) a directive
    pub fn directive<I, S>(mut self, name: &str, values: I) -> Self
    where
        I: IntoIterator<Item = S>,
        S: Into<String>,
    {
        let values: Vec<String> = values.into_iter().map(Into::into).collect();
        match self.directives.iter_mut().find(|(n, _)| n == name) {
            Some((_, v)) => *v = values,
            None => self.directives.push((name.to_string(), values)),
        }
        self
    }

    pub fn script_src<I, S>(self, values: I) -> Self
    where
        I: IntoIterator<Item = S>,
        S: Into<String>,
    {
        self.directive("script-src", values)
    }

    pub fn style_src<I, S>(self, values: I) -> Self
    where
        I: IntoIterator<Item = S>,
        S: Into<String>,
    {
        self.directive("style-src", values)
    }

    pub fn img_src<I, S>(self, values: I) -> Self
    where
        I: IntoIterator<Item = S>,
        S: Into<String>,
    {
        self.directive("img-src", values)
    }

    pub fn connect_src<I, S>(self, values: I) -> Self
    where
        I: IntoIterator<Item = S>,
        S: Into<String>,
    {
        self.directive("connect-src", values)
    }

    /// Where browsers send violation reports (`report-uri`)
    pub fn report_uri(self, uri: &str) -> Self {
        self.directive("report-uri", [uri])
    }

    /// Deliver as `Content-Security-Policy-Report-Only`: violations are reported,
    /// nothing is blocked. Useful to roll a policy out.
    pub fn report_only(mut self) -> Self {
        self.report_only = true;
        self
    }

    fn render(&self, nonce: &str) -> String {
        self.directives
            .iter()
            .map(|(name, values)| {
                if values.is_empty() {
                    name.clone()
                } else {
                    format!("{} {}", name, values.join(" ").replace("{nonce}", nonce))
                }
            })
            .collect::<Vec<_>>()
            .join("; ")
    }
}

/// Value of `X-Frame-Options`
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum FrameOptions {
    Deny,
    SameOrigin,
}

/// Middleware adding security headers, see the [module documentation](self)
#[derive(Clone, Debug)]
pub struct SecurityHeaders {
    csp: Option<Csp>,
    frame_options: Option<FrameOptions>,
    headers: Vec<(String, String)>,
    compiled: std::sync::OnceLock<Compiled>,
}

/// Header names and values parsed once, on the first request
#[derive(Clone, Debug)]
struct Compiled {
    fixed: Vec<(http::HeaderName, http::HeaderValue)>,
    csp: Option<CompiledCsp>,
}

#[derive(Clone, Debug)]
struct CompiledCsp {
    name: http::HeaderName,
    /// The policy cut at every `{nonce}`: n parts mean n - 1 nonces
    parts: Vec<String>,
    /// Ready value when the policy has no nonce
    fixed: Option<http::HeaderValue>,
}

impl Default for SecurityHeaders {
    fn default() -> Self {
        Self::new()
    }
}

impl SecurityHeaders {
    pub fn new() -> Self {
        Self {
            csp: None,
            compiled: std::sync::OnceLock::new(),
            frame_options: Some(FrameOptions::Deny),
            headers: vec![
                ("x-content-type-options".into(), "nosniff".into()),
                ("referrer-policy".into(), "no-referrer".into()),
                ("cross-origin-opener-policy".into(), "same-origin".into()),
                ("cross-origin-resource-policy".into(), "same-origin".into()),
                (
                    "permissions-policy".into(),
                    "camera=(), microphone=(), geolocation=(), payment=()".into(),
                ),
            ],
        }
    }

    /// Send a Content-Security-Policy (none by default: a good policy depends on the app)
    pub fn csp(mut self, csp: Csp) -> Self {
        self.csp = Some(csp);
        self
    }

    /// `X-Frame-Options` (default `DENY`); `None` omits it, e.g. when the CSP's
    /// `frame-ancestors` should be the only rule
    pub fn frame_options(mut self, options: impl Into<Option<FrameOptions>>) -> Self {
        self.frame_options = options.into();
        self
    }

    /// Set or replace any other header
    pub fn header(mut self, name: &str, value: &str) -> Self {
        let name = name.to_ascii_lowercase();
        match self.headers.iter_mut().find(|(n, _)| *n == name) {
            Some((_, v)) => *v = value.to_string(),
            None => self.headers.push((name, value.to_string())),
        }
        self
    }

    /// Drop one of the default headers
    pub fn without(mut self, name: &str) -> Self {
        let name = name.to_ascii_lowercase();
        self.headers.retain(|(n, _)| *n != name);
        self
    }
}

impl SecurityHeaders {
    fn compile(&self) -> Compiled {
        let mut fixed = Vec::new();
        let mut push = |name: &str, value: &str| {
            if let (Ok(n), Ok(v)) = (
                http::HeaderName::from_bytes(name.as_bytes()),
                http::HeaderValue::from_str(value),
            ) {
                fixed.push((n, v));
            }
        };
        for (name, value) in &self.headers {
            push(name, value);
        }
        match self.frame_options {
            Some(FrameOptions::Deny) => push("x-frame-options", "DENY"),
            Some(FrameOptions::SameOrigin) => push("x-frame-options", "SAMEORIGIN"),
            None => {}
        }
        let csp = self.csp.as_ref().and_then(|csp| {
            let name = if csp.report_only {
                "content-security-policy-report-only"
            } else {
                "content-security-policy"
            };
            let text = csp.render("{nonce}");
            let parts: Vec<String> = text.split("{nonce}").map(str::to_string).collect();
            let fixed = (parts.len() == 1)
                .then(|| http::HeaderValue::from_str(&text).ok())
                .flatten();
            Some(CompiledCsp {
                name: http::HeaderName::from_bytes(name.as_bytes()).ok()?,
                parts,
                fixed,
            })
        });
        Compiled { fixed, csp }
    }
}

#[async_trait]
impl Middleware for SecurityHeaders {
    async fn handle(&self, mut req: Request, next: Next) -> Result<Response> {
        let compiled = self.compiled.get_or_init(|| self.compile());
        let mut nonce = None;
        if compiled.csp.as_ref().is_some_and(|c| c.parts.len() > 1) {
            let n = crate::security::random_token(16)?;
            req.extensions.insert(CspNonce(n.clone()));
            nonce = Some(n);
        }
        let mut res = next.run(req).await.unwrap_or_else(Response::from_error);

        res.headers.reserve(compiled.fixed.len() + 1);
        for (name, value) in &compiled.fixed {
            if !res.headers.contains_key(name) {
                res.headers.insert(name.clone(), value.clone());
            }
        }
        if let Some(csp) = &compiled.csp {
            if !res.headers.contains_key(&csp.name) {
                let value = match (&csp.fixed, &nonce) {
                    (Some(v), _) => Some(v.clone()),
                    (None, Some(n)) => http::HeaderValue::from_str(&csp.parts.join(n)).ok(),
                    (None, None) => None,
                };
                if let Some(v) = value {
                    res.headers.insert(csp.name.clone(), v);
                }
            }
        }
        Ok(res)
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn renders_directives_and_nonce() {
        let csp = Csp::web().script_src(["'self'", "'nonce-{nonce}'"]);
        let text = csp.render("abc");
        assert!(text.starts_with("default-src 'self'; "));
        assert!(text.contains("script-src 'self' 'nonce-abc'"));
        assert!(text.contains("frame-ancestors 'none'"));
    }

    #[test]
    fn directive_replaces_instead_of_duplicating() {
        let csp = Csp::api().directive("default-src", ["'self'"]);
        assert_eq!(csp.render(""), "default-src 'self'; frame-ancestors 'none'");
    }

    #[test]
    fn valueless_directive_renders_as_a_flag() {
        let csp = Csp::new().directive("upgrade-insecure-requests", Vec::<String>::new());
        assert_eq!(csp.render(""), "upgrade-insecure-requests");
    }
}