AkurAI Build
Menu

Public repository / master

AkurAI-Framework / crates/http/src/middleware_builtins.rs

Latest change fc42a695fc258e2e68d465fc233baaaa56b9c7f0 - OLI-157: configurable SecurityPolicy middleware ([security] in akurai.toml) by Ólafur Búi Ólafsson

//! Ready-to-use [`Middleware`](crate::Middleware) implementations.
//!
//! These are small, dependency-free examples of the chain in action:
//! [`SecurityHeaders`] hardens every response and [`Timing`] reports how long
//! the handler took. Push them onto a
//! [`MiddlewareStack`](crate::MiddlewareStack) in the order you want them to
//! wrap (first pushed = outermost). Request logging is no longer a middleware:
//! the server itself writes an access log by default (see
//! [`Server::access_log`](crate::Server::access_log)).

use crate::{Middleware, Reply, Request};
use std::time::Instant;

/// Adds a conservative set of security headers to every [`Response`].
///
/// * `X-Content-Type-Options: nosniff` — don't MIME-sniff the body.
/// * `X-Frame-Options: DENY` — never allow framing (clickjacking guard).
/// * `Referrer-Policy: no-referrer` — never leak the URL in the `Referer`.
///
/// Existing headers of the same name are replaced (see [`Response::with_header`]).
/// [`Upgrade`](crate::Upgrade) replies pass through untouched — their head is
/// owned by the upgraded protocol.
pub struct SecurityHeaders;

impl Middleware for SecurityHeaders {
    fn handle(&self, req: &Request, next: &dyn Fn(&Request) -> Reply) -> Reply {
        match next(req) {
            Reply::Response(resp) => Reply::Response(
                resp.with_header("X-Content-Type-Options", "nosniff")
                    .with_header("X-Frame-Options", "DENY")
                    .with_header("Referrer-Policy", "no-referrer"),
            ),
            other => other,
        }
    }
}

/// Configurable security-header middleware with a strict default policy.
///
/// Defaults: CSP `default-src 'self'` (+ `object-src 'none'`, `base-uri 'self'`,
/// `form-action 'self'`), `frame-ancestors 'none'`, HSTS one year with
/// subdomains, `nosniff`, `Referrer-Policy: no-referrer` and a deny-all
/// `Permissions-Policy`. Each header can be overridden or disabled (empty
/// string / `None`). `X-Frame-Options` is derived from `frame_ancestors`.
#[derive(Clone, Debug)]
pub struct SecurityPolicy {
    pub csp: Option<String>,
    pub frame_ancestors: String,
    pub hsts: Option<String>,
    pub nosniff: bool,
    pub referrer_policy: Option<String>,
    pub permissions_policy: Option<String>,
}

impl Default for SecurityPolicy {
    fn default() -> Self {
        SecurityPolicy {
            csp: Some(
                "default-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'".into(),
            ),
            frame_ancestors: "'none'".into(),
            hsts: Some("max-age=31536000; includeSubDomains".into()),
            nosniff: true,
            referrer_policy: Some("no-referrer".into()),
            permissions_policy: Some(
                "camera=(), microphone=(), geolocation=(), payment=(), usb=()".into(),
            ),
        }
    }
}

impl SecurityPolicy {
    /// Full `Content-Security-Policy` value including `frame-ancestors`.
    pub fn csp_value(&self) -> Option<String> {
        let base = self.csp.as_deref()?.trim().trim_end_matches(';');
        let fa = self.frame_ancestors.trim();
        Some(if fa.is_empty() || base.contains("frame-ancestors") {
            base.to_string()
        } else {
            format!("{base}; frame-ancestors {fa}")
        })
    }

    fn x_frame_options(&self) -> Option<&'static str> {
        match self.frame_ancestors.trim() {
            "'none'" => Some("DENY"),
            "'self'" => Some("SAMEORIGIN"),
            _ => None,
        }
    }
}

impl Middleware for SecurityPolicy {
    fn handle(&self, req: &Request, next: &dyn Fn(&Request) -> Reply) -> Reply {
        let mut resp = match next(req) {
            Reply::Response(r) => r,
            other => return other,
        };
        if let Some(v) = self.csp_value() {
            resp = resp.with_header("Content-Security-Policy", &v);
        }
        if let Some(v) = self.x_frame_options() {
            resp = resp.with_header("X-Frame-Options", v);
        }
        if let Some(v) = &self.hsts {
            resp = resp.with_header("Strict-Transport-Security", v);
        }
        if self.nosniff {
            resp = resp.with_header("X-Content-Type-Options", "nosniff");
        }
        if let Some(v) = &self.referrer_policy {
            resp = resp.with_header("Referrer-Policy", v);
        }
        if let Some(v) = &self.permissions_policy {
            resp = resp.with_header("Permissions-Policy", v);
        }
        Reply::Response(resp)
    }
}

/// Times the inner chain and stamps the elapsed wall-clock microseconds onto the
/// response as `X-Response-Time-Us`. [`Upgrade`](crate::Upgrade) replies (whose
/// lifetime is unbounded) are left untouched.
pub struct Timing;

impl Middleware for Timing {
    fn handle(&self, req: &Request, next: &dyn Fn(&Request) -> Reply) -> Reply {
        let start = Instant::now();
        match next(req) {
            Reply::Response(resp) => {
                let micros = start.elapsed().as_micros();
                Reply::Response(resp.with_header("X-Response-Time-Us", &micros.to_string()))
            }
            other => other,
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::{MiddlewareStack, Response};

    fn get(path: &str) -> Request {
        Request::parse_head(format!("GET {path} HTTP/1.1\r\n\r\n").as_bytes()).unwrap()
    }

    fn response(reply: Reply) -> Response {
        match reply {
            Reply::Response(r) => r,
            Reply::Upgrade(_) => panic!("expected a Response"),
        }
    }

    fn header<'a>(r: &'a Response, name: &str) -> Option<&'a str> {
        r.headers
            .iter()
            .find(|(k, _)| k.eq_ignore_ascii_case(name))
            .map(|(_, v)| v.as_str())
    }

    #[test]
    fn security_headers_are_applied() {
        let stack = MiddlewareStack::new().push(SecurityHeaders);
        let resp = response(stack.handle(&get("/"), &|_| Reply::Response(Response::ok())));
        assert_eq!(header(&resp, "X-Content-Type-Options"), Some("nosniff"));
        assert_eq!(header(&resp, "X-Frame-Options"), Some("DENY"));
        assert_eq!(header(&resp, "Referrer-Policy"), Some("no-referrer"));
    }

    #[test]
    fn policy_defaults_are_strict() {
        let stack = MiddlewareStack::new().push(SecurityPolicy::default());
        let resp = response(stack.handle(&get("/"), &|_| Reply::Response(Response::ok())));
        let csp = header(&resp, "Content-Security-Policy").unwrap();
        assert!(csp.starts_with("default-src 'self'"));
        assert!(csp.ends_with("frame-ancestors 'none'"));
        assert_eq!(header(&resp, "X-Frame-Options"), Some("DENY"));
        assert!(header(&resp, "Strict-Transport-Security")
            .unwrap()
            .contains("max-age="));
        assert_eq!(header(&resp, "X-Content-Type-Options"), Some("nosniff"));
        assert_eq!(header(&resp, "Referrer-Policy"), Some("no-referrer"));
        assert!(header(&resp, "Permissions-Policy")
            .unwrap()
            .contains("camera=()"));
    }

    #[test]
    fn policy_overrides_and_disables() {
        let p = SecurityPolicy {
            csp: Some("default-src 'self' https://cdn.example".into()),
            frame_ancestors: "'self'".into(),
            hsts: None,
            ..SecurityPolicy::default()
        };
        let stack = MiddlewareStack::new().push(p);
        let resp = response(stack.handle(&get("/"), &|_| Reply::Response(Response::ok())));
        assert_eq!(
            header(&resp, "Content-Security-Policy"),
            Some("default-src 'self' https://cdn.example; frame-ancestors 'self'")
        );
        assert_eq!(header(&resp, "X-Frame-Options"), Some("SAMEORIGIN"));
        assert_eq!(header(&resp, "Strict-Transport-Security"), None);
    }

    #[test]
    fn timing_stamps_a_microsecond_header() {
        let stack = MiddlewareStack::new().push(Timing);
        let resp = response(stack.handle(&get("/"), &|_| Reply::Response(Response::ok())));
        let v = header(&resp, "X-Response-Time-Us").expect("timing header present");
        assert!(v.parse::<u128>().is_ok(), "got: {v:?}");
    }
}