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", µs.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:?}");
}
}