Skip to main content

ullun/
security_headers.rs

1//! Security response headers, including a Content-Security-Policy builder
2//!
3//! [`SecurityHeaders`] adds the headers every response should carry. A header
4//! the handler already set is left alone, so one route can loosen the policy.
5//!
6//! ```no_run
7//! use ullun::prelude::*;
8//! use ullun::security_headers::{Csp, SecurityHeaders};
9//!
10//! # async fn run() {
11//! App::new()
12//!     .middleware(SecurityHeaders::new().csp(Csp::web().script_src(["'self'", "'nonce-{nonce}'"])))
13//!     .get("/", |req: Request| async move {
14//!         // the nonce is fresh for every request
15//!         let nonce = req.csp_nonce().unwrap_or_default().to_string();
16//!         Ok(Response::html(format!("<script nonce=\"{nonce}\">console.log(1)</script>")))
17//!     })
18//!     .run("127.0.0.1:3000")
19//!     .await
20//!     .unwrap();
21//! # }
22//! ```
23//!
24//! Defaults of [`SecurityHeaders::new`]: `X-Content-Type-Options: nosniff`,
25//! `X-Frame-Options: DENY`, `Referrer-Policy: no-referrer`,
26//! `Cross-Origin-Opener-Policy: same-origin`,
27//! `Cross-Origin-Resource-Policy: same-origin` and a `Permissions-Policy` that
28//! switches off camera, microphone, geolocation and payment. HSTS is a separate
29//! middleware ([`crate::secure::Hsts`]) because it only belongs on HTTPS.
30
31use crate::error::Result;
32use crate::middleware::{Middleware, Next};
33use crate::request::Request;
34use crate::response::Response;
35use async_trait::async_trait;
36
37/// The per-request nonce generated for a policy that uses the `{nonce}` placeholder
38#[derive(Clone, Debug)]
39pub(crate) struct CspNonce(pub(crate) String);
40
41/// A Content-Security-Policy under construction
42///
43/// A value of `'nonce-{nonce}'` in any directive is replaced with a fresh random
44/// nonce per request, readable through [`Request::csp_nonce`] for inline
45/// `<script nonce="...">` tags.
46#[derive(Clone, Debug, Default)]
47pub struct Csp {
48    directives: Vec<(String, Vec<String>)>,
49    report_only: bool,
50}
51
52impl Csp {
53    /// Empty policy
54    pub fn new() -> Self {
55        Self::default()
56    }
57
58    /// For JSON APIs: nothing may load or embed anything
59    pub fn api() -> Self {
60        Self::new()
61            .directive("default-src", ["'none'"])
62            .directive("frame-ancestors", ["'none'"])
63    }
64
65    /// For server-rendered pages: same-origin resources only, no plugins, no
66    /// framing, forms post to the same origin
67    pub fn web() -> Self {
68        Self::new()
69            .directive("default-src", ["'self'"])
70            .directive("base-uri", ["'self'"])
71            .directive("form-action", ["'self'"])
72            .directive("frame-ancestors", ["'none'"])
73            .directive("object-src", ["'none'"])
74    }
75
76    /// Set (replace) a directive
77    pub fn directive<I, S>(mut self, name: &str, values: I) -> Self
78    where
79        I: IntoIterator<Item = S>,
80        S: Into<String>,
81    {
82        let values: Vec<String> = values.into_iter().map(Into::into).collect();
83        match self.directives.iter_mut().find(|(n, _)| n == name) {
84            Some((_, v)) => *v = values,
85            None => self.directives.push((name.to_string(), values)),
86        }
87        self
88    }
89
90    pub fn script_src<I, S>(self, values: I) -> Self
91    where
92        I: IntoIterator<Item = S>,
93        S: Into<String>,
94    {
95        self.directive("script-src", values)
96    }
97
98    pub fn style_src<I, S>(self, values: I) -> Self
99    where
100        I: IntoIterator<Item = S>,
101        S: Into<String>,
102    {
103        self.directive("style-src", values)
104    }
105
106    pub fn img_src<I, S>(self, values: I) -> Self
107    where
108        I: IntoIterator<Item = S>,
109        S: Into<String>,
110    {
111        self.directive("img-src", values)
112    }
113
114    pub fn connect_src<I, S>(self, values: I) -> Self
115    where
116        I: IntoIterator<Item = S>,
117        S: Into<String>,
118    {
119        self.directive("connect-src", values)
120    }
121
122    /// Where browsers send violation reports (`report-uri`)
123    pub fn report_uri(self, uri: &str) -> Self {
124        self.directive("report-uri", [uri])
125    }
126
127    /// Deliver as `Content-Security-Policy-Report-Only`: violations are reported,
128    /// nothing is blocked. Useful to roll a policy out.
129    pub fn report_only(mut self) -> Self {
130        self.report_only = true;
131        self
132    }
133
134    fn render(&self, nonce: &str) -> String {
135        self.directives
136            .iter()
137            .map(|(name, values)| {
138                if values.is_empty() {
139                    name.clone()
140                } else {
141                    format!("{} {}", name, values.join(" ").replace("{nonce}", nonce))
142                }
143            })
144            .collect::<Vec<_>>()
145            .join("; ")
146    }
147}
148
149/// Value of `X-Frame-Options`
150#[derive(Clone, Copy, Debug, PartialEq, Eq)]
151pub enum FrameOptions {
152    Deny,
153    SameOrigin,
154}
155
156/// Middleware adding security headers, see the [module documentation](self)
157#[derive(Clone, Debug)]
158pub struct SecurityHeaders {
159    csp: Option<Csp>,
160    frame_options: Option<FrameOptions>,
161    headers: Vec<(String, String)>,
162    compiled: std::sync::OnceLock<Compiled>,
163}
164
165/// Header names and values parsed once, on the first request
166#[derive(Clone, Debug)]
167struct Compiled {
168    fixed: Vec<(http::HeaderName, http::HeaderValue)>,
169    csp: Option<CompiledCsp>,
170}
171
172#[derive(Clone, Debug)]
173struct CompiledCsp {
174    name: http::HeaderName,
175    /// The policy cut at every `{nonce}`: n parts mean n - 1 nonces
176    parts: Vec<String>,
177    /// Ready value when the policy has no nonce
178    fixed: Option<http::HeaderValue>,
179}
180
181impl Default for SecurityHeaders {
182    fn default() -> Self {
183        Self::new()
184    }
185}
186
187impl SecurityHeaders {
188    pub fn new() -> Self {
189        Self {
190            csp: None,
191            compiled: std::sync::OnceLock::new(),
192            frame_options: Some(FrameOptions::Deny),
193            headers: vec![
194                ("x-content-type-options".into(), "nosniff".into()),
195                ("referrer-policy".into(), "no-referrer".into()),
196                ("cross-origin-opener-policy".into(), "same-origin".into()),
197                ("cross-origin-resource-policy".into(), "same-origin".into()),
198                (
199                    "permissions-policy".into(),
200                    "camera=(), microphone=(), geolocation=(), payment=()".into(),
201                ),
202            ],
203        }
204    }
205
206    /// Send a Content-Security-Policy (none by default: a good policy depends on the app)
207    pub fn csp(mut self, csp: Csp) -> Self {
208        self.csp = Some(csp);
209        self
210    }
211
212    /// `X-Frame-Options` (default `DENY`); `None` omits it, e.g. when the CSP's
213    /// `frame-ancestors` should be the only rule
214    pub fn frame_options(mut self, options: impl Into<Option<FrameOptions>>) -> Self {
215        self.frame_options = options.into();
216        self
217    }
218
219    /// Set or replace any other header
220    pub fn header(mut self, name: &str, value: &str) -> Self {
221        let name = name.to_ascii_lowercase();
222        match self.headers.iter_mut().find(|(n, _)| *n == name) {
223            Some((_, v)) => *v = value.to_string(),
224            None => self.headers.push((name, value.to_string())),
225        }
226        self
227    }
228
229    /// Drop one of the default headers
230    pub fn without(mut self, name: &str) -> Self {
231        let name = name.to_ascii_lowercase();
232        self.headers.retain(|(n, _)| *n != name);
233        self
234    }
235}
236
237impl SecurityHeaders {
238    fn compile(&self) -> Compiled {
239        let mut fixed = Vec::new();
240        let mut push = |name: &str, value: &str| {
241            if let (Ok(n), Ok(v)) = (
242                http::HeaderName::from_bytes(name.as_bytes()),
243                http::HeaderValue::from_str(value),
244            ) {
245                fixed.push((n, v));
246            }
247        };
248        for (name, value) in &self.headers {
249            push(name, value);
250        }
251        match self.frame_options {
252            Some(FrameOptions::Deny) => push("x-frame-options", "DENY"),
253            Some(FrameOptions::SameOrigin) => push("x-frame-options", "SAMEORIGIN"),
254            None => {}
255        }
256        let csp = self.csp.as_ref().and_then(|csp| {
257            let name = if csp.report_only {
258                "content-security-policy-report-only"
259            } else {
260                "content-security-policy"
261            };
262            let text = csp.render("{nonce}");
263            let parts: Vec<String> = text.split("{nonce}").map(str::to_string).collect();
264            let fixed = (parts.len() == 1)
265                .then(|| http::HeaderValue::from_str(&text).ok())
266                .flatten();
267            Some(CompiledCsp {
268                name: http::HeaderName::from_bytes(name.as_bytes()).ok()?,
269                parts,
270                fixed,
271            })
272        });
273        Compiled { fixed, csp }
274    }
275}
276
277#[async_trait]
278impl Middleware for SecurityHeaders {
279    async fn handle(&self, mut req: Request, next: Next) -> Result<Response> {
280        let compiled = self.compiled.get_or_init(|| self.compile());
281        let mut nonce = None;
282        if compiled.csp.as_ref().is_some_and(|c| c.parts.len() > 1) {
283            let n = crate::security::random_token(16)?;
284            req.extensions.insert(CspNonce(n.clone()));
285            nonce = Some(n);
286        }
287        let mut res = next.run(req).await.unwrap_or_else(Response::from_error);
288
289        res.headers.reserve(compiled.fixed.len() + 1);
290        for (name, value) in &compiled.fixed {
291            if !res.headers.contains_key(name) {
292                res.headers.insert(name.clone(), value.clone());
293            }
294        }
295        if let Some(csp) = &compiled.csp {
296            if !res.headers.contains_key(&csp.name) {
297                let value = match (&csp.fixed, &nonce) {
298                    (Some(v), _) => Some(v.clone()),
299                    (None, Some(n)) => http::HeaderValue::from_str(&csp.parts.join(n)).ok(),
300                    (None, None) => None,
301                };
302                if let Some(v) = value {
303                    res.headers.insert(csp.name.clone(), v);
304                }
305            }
306        }
307        Ok(res)
308    }
309}
310
311#[cfg(test)]
312mod tests {
313    use super::*;
314
315    #[test]
316    fn renders_directives_and_nonce() {
317        let csp = Csp::web().script_src(["'self'", "'nonce-{nonce}'"]);
318        let text = csp.render("abc");
319        assert!(text.starts_with("default-src 'self'; "));
320        assert!(text.contains("script-src 'self' 'nonce-abc'"));
321        assert!(text.contains("frame-ancestors 'none'"));
322    }
323
324    #[test]
325    fn directive_replaces_instead_of_duplicating() {
326        let csp = Csp::api().directive("default-src", ["'self'"]);
327        assert_eq!(csp.render(""), "default-src 'self'; frame-ancestors 'none'");
328    }
329
330    #[test]
331    fn valueless_directive_renders_as_a_flag() {
332        let csp = Csp::new().directive("upgrade-insecure-requests", Vec::<String>::new());
333        assert_eq!(csp.render(""), "upgrade-insecure-requests");
334    }
335}