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 uses_nonce(&self) -> bool {
135        self.directives
136            .iter()
137            .any(|(_, v)| v.iter().any(|s| s.contains("{nonce}")))
138    }
139
140    fn render(&self, nonce: &str) -> String {
141        self.directives
142            .iter()
143            .map(|(name, values)| {
144                if values.is_empty() {
145                    name.clone()
146                } else {
147                    format!("{} {}", name, values.join(" ").replace("{nonce}", nonce))
148                }
149            })
150            .collect::<Vec<_>>()
151            .join("; ")
152    }
153}
154
155/// Value of `X-Frame-Options`
156#[derive(Clone, Copy, Debug, PartialEq, Eq)]
157pub enum FrameOptions {
158    Deny,
159    SameOrigin,
160}
161
162/// Middleware adding security headers, see the [module documentation](self)
163#[derive(Clone, Debug)]
164pub struct SecurityHeaders {
165    csp: Option<Csp>,
166    frame_options: Option<FrameOptions>,
167    headers: Vec<(String, String)>,
168}
169
170impl Default for SecurityHeaders {
171    fn default() -> Self {
172        Self::new()
173    }
174}
175
176impl SecurityHeaders {
177    pub fn new() -> Self {
178        Self {
179            csp: None,
180            frame_options: Some(FrameOptions::Deny),
181            headers: vec![
182                ("x-content-type-options".into(), "nosniff".into()),
183                ("referrer-policy".into(), "no-referrer".into()),
184                ("cross-origin-opener-policy".into(), "same-origin".into()),
185                ("cross-origin-resource-policy".into(), "same-origin".into()),
186                (
187                    "permissions-policy".into(),
188                    "camera=(), microphone=(), geolocation=(), payment=()".into(),
189                ),
190            ],
191        }
192    }
193
194    /// Send a Content-Security-Policy (none by default: a good policy depends on the app)
195    pub fn csp(mut self, csp: Csp) -> Self {
196        self.csp = Some(csp);
197        self
198    }
199
200    /// `X-Frame-Options` (default `DENY`); `None` omits it, e.g. when the CSP's
201    /// `frame-ancestors` should be the only rule
202    pub fn frame_options(mut self, options: impl Into<Option<FrameOptions>>) -> Self {
203        self.frame_options = options.into();
204        self
205    }
206
207    /// Set or replace any other header
208    pub fn header(mut self, name: &str, value: &str) -> Self {
209        let name = name.to_ascii_lowercase();
210        match self.headers.iter_mut().find(|(n, _)| *n == name) {
211            Some((_, v)) => *v = value.to_string(),
212            None => self.headers.push((name, value.to_string())),
213        }
214        self
215    }
216
217    /// Drop one of the default headers
218    pub fn without(mut self, name: &str) -> Self {
219        let name = name.to_ascii_lowercase();
220        self.headers.retain(|(n, _)| *n != name);
221        self
222    }
223}
224
225#[async_trait]
226impl Middleware for SecurityHeaders {
227    async fn handle(&self, mut req: Request, next: Next) -> Result<Response> {
228        let mut nonce = String::new();
229        if self.csp.as_ref().is_some_and(Csp::uses_nonce) {
230            nonce = crate::security::random_token(16)?;
231            req.extensions.insert(CspNonce(nonce.clone()));
232        }
233        let mut res = next.run(req).await.unwrap_or_else(Response::from_error);
234
235        let put = |res: &mut Response, name: &str, value: String| {
236            if !res.headers.contains_key(name) {
237                if let (Ok(n), Ok(v)) = (
238                    http::HeaderName::from_bytes(name.as_bytes()),
239                    http::HeaderValue::from_str(&value),
240                ) {
241                    res.headers.insert(n, v);
242                }
243            }
244        };
245        for (name, value) in &self.headers {
246            put(&mut res, name, value.clone());
247        }
248        match self.frame_options {
249            Some(FrameOptions::Deny) => put(&mut res, "x-frame-options", "DENY".into()),
250            Some(FrameOptions::SameOrigin) => put(&mut res, "x-frame-options", "SAMEORIGIN".into()),
251            None => {}
252        }
253        if let Some(csp) = &self.csp {
254            let name = if csp.report_only {
255                "content-security-policy-report-only"
256            } else {
257                "content-security-policy"
258            };
259            put(&mut res, name, csp.render(&nonce));
260        }
261        Ok(res)
262    }
263}
264
265#[cfg(test)]
266mod tests {
267    use super::*;
268
269    #[test]
270    fn renders_directives_and_nonce() {
271        let csp = Csp::web().script_src(["'self'", "'nonce-{nonce}'"]);
272        assert!(csp.uses_nonce());
273        let text = csp.render("abc");
274        assert!(text.starts_with("default-src 'self'; "));
275        assert!(text.contains("script-src 'self' 'nonce-abc'"));
276        assert!(text.contains("frame-ancestors 'none'"));
277    }
278
279    #[test]
280    fn directive_replaces_instead_of_duplicating() {
281        let csp = Csp::api().directive("default-src", ["'self'"]);
282        assert_eq!(csp.render(""), "default-src 'self'; frame-ancestors 'none'");
283    }
284
285    #[test]
286    fn valueless_directive_renders_as_a_flag() {
287        let csp = Csp::new().directive("upgrade-insecure-requests", Vec::<String>::new());
288        assert_eq!(csp.render(""), "upgrade-insecure-requests");
289    }
290}