Skip to main content

web_net/
headers.rs

1//! The security headers every reply carries, from one policy.
2
3/// The security headers added to every reply web-net sends (pages, error
4/// pages, files, redirects), unless the handler set the same field itself.
5///
6/// The defaults are strict: a page may load scripts, styles, images, and
7/// fonts from its own origin only, may not be framed, and sends a short
8/// referrer to other origins. A site that needs more says so.
9///
10/// ```
11/// use web_net::SecurityHeaders;
12///
13/// let policy = SecurityHeaders::default()
14///     .content_security_policy("default-src 'self'; img-src 'self' https://cdn.example; frame-ancestors 'none'")
15///     .hsts(63_072_000, true);
16/// # let _ = policy;
17/// ```
18#[derive(Clone, Debug, PartialEq, Eq)]
19pub struct SecurityHeaders {
20    pub(crate) csp: Option<Box<str>>,
21    pub(crate) frame: Option<Box<str>>,
22    pub(crate) referrer: Option<Box<str>>,
23    pub(crate) permissions: Option<Box<str>>,
24    pub(crate) coop: Option<Box<str>>,
25    pub(crate) hsts: Option<Box<str>>,
26}
27
28/// The default content security policy.
29///
30/// ```
31/// use web_net::{App, DEFAULT_CSP, get};
32///
33/// let app = App::builder().route(get("/", |cx| cx.text(200, "ok"))).build()?;
34/// let r = serve_net::testing::respond(&app, b"GET / HTTP/1.1\r\nHost: localhost\r\n\r\n");
35/// assert_eq!(r.header("content-security-policy"), Some(DEFAULT_CSP.as_bytes()));
36/// # Ok::<(), web_net::BuildError>(())
37/// ```
38pub const DEFAULT_CSP: &str = "default-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'";
39
40impl Default for SecurityHeaders {
41    fn default() -> Self {
42        Self {
43            csp: Some(DEFAULT_CSP.into()),
44            frame: Some("DENY".into()),
45            referrer: Some("strict-origin-when-cross-origin".into()),
46            permissions: Some("camera=(), microphone=(), geolocation=(), payment=()".into()),
47            coop: Some("same-origin".into()),
48            hsts: Some("max-age=31536000".into()),
49        }
50    }
51}
52
53impl SecurityHeaders {
54    /// No security headers at all (`x-content-type-options: nosniff` is
55    /// still sent: it has no cost and no downside).
56    ///
57    /// ```
58    /// use web_net::{App, SecurityHeaders, get};
59    ///
60    /// let app = App::builder()
61    ///     .security_headers(SecurityHeaders::none())
62    ///     .route(get("/", |cx| cx.text(200, "ok")))
63    ///     .build()?;
64    /// let r = serve_net::testing::respond(&app, b"GET / HTTP/1.1\r\nHost: localhost\r\n\r\n");
65    /// assert!(r.header("content-security-policy").is_none());
66    /// assert_eq!(r.header("x-content-type-options"), Some(&b"nosniff"[..]));
67    /// # Ok::<(), web_net::BuildError>(())
68    /// ```
69    #[must_use]
70    pub fn none() -> Self {
71        Self {
72            csp: None,
73            frame: None,
74            referrer: None,
75            permissions: None,
76            coop: None,
77            hsts: None,
78        }
79    }
80
81    /// `Content-Security-Policy`.
82    ///
83    /// ```
84    /// use web_net::{App, SecurityHeaders, get};
85    ///
86    /// let app = App::builder()
87    ///     .security_headers(SecurityHeaders::default().content_security_policy("default-src 'self'"))
88    ///     .route(get("/", |cx| cx.text(200, "ok")))
89    ///     .build()?;
90    /// let r = serve_net::testing::respond(&app, b"GET / HTTP/1.1\r\nHost: localhost\r\n\r\n");
91    /// assert_eq!(r.header("content-security-policy"), Some(&b"default-src 'self'"[..]));
92    /// # Ok::<(), web_net::BuildError>(())
93    /// ```
94    #[must_use]
95    pub fn content_security_policy(mut self, value: &str) -> Self {
96        self.csp = Some(value.into());
97        self
98    }
99
100    /// No `Content-Security-Policy`.
101    ///
102    /// ```
103    /// use web_net::{App, SecurityHeaders, get};
104    ///
105    /// let app = App::builder()
106    ///     .security_headers(SecurityHeaders::default().no_content_security_policy())
107    ///     .route(get("/", |cx| cx.text(200, "ok")))
108    ///     .build()?;
109    /// let r = serve_net::testing::respond(&app, b"GET / HTTP/1.1\r\nHost: localhost\r\n\r\n");
110    /// assert_eq!(r.header("content-security-policy"), None);
111    /// # Ok::<(), web_net::BuildError>(())
112    /// ```
113    #[must_use]
114    pub fn no_content_security_policy(mut self) -> Self {
115        self.csp = None;
116        self
117    }
118
119    /// `X-Frame-Options` (`DENY` by default; `SAMEORIGIN` to allow the site's
120    /// own frames).
121    ///
122    /// ```
123    /// use web_net::{App, SecurityHeaders, get};
124    ///
125    /// let app = App::builder()
126    ///     .security_headers(SecurityHeaders::default().frame_options("SAMEORIGIN"))
127    ///     .route(get("/", |cx| cx.text(200, "ok")))
128    ///     .build()?;
129    /// let r = serve_net::testing::respond(&app, b"GET / HTTP/1.1\r\nHost: localhost\r\n\r\n");
130    /// assert_eq!(r.header("x-frame-options"), Some(&b"SAMEORIGIN"[..]));
131    /// # Ok::<(), web_net::BuildError>(())
132    /// ```
133    #[must_use]
134    pub fn frame_options(mut self, value: &str) -> Self {
135        self.frame = Some(value.into());
136        self
137    }
138
139    /// `Referrer-Policy`.
140    ///
141    /// ```
142    /// use web_net::{App, SecurityHeaders, get};
143    ///
144    /// let app = App::builder()
145    ///     .security_headers(SecurityHeaders::default().referrer_policy("no-referrer"))
146    ///     .route(get("/", |cx| cx.text(200, "ok")))
147    ///     .build()?;
148    /// let r = serve_net::testing::respond(&app, b"GET / HTTP/1.1\r\nHost: localhost\r\n\r\n");
149    /// assert_eq!(r.header("referrer-policy"), Some(&b"no-referrer"[..]));
150    /// # Ok::<(), web_net::BuildError>(())
151    /// ```
152    #[must_use]
153    pub fn referrer_policy(mut self, value: &str) -> Self {
154        self.referrer = Some(value.into());
155        self
156    }
157
158    /// `Permissions-Policy`.
159    ///
160    /// ```
161    /// use web_net::{App, SecurityHeaders, get};
162    ///
163    /// let app = App::builder()
164    ///     .security_headers(SecurityHeaders::default().permissions_policy("camera=(self)"))
165    ///     .route(get("/", |cx| cx.text(200, "ok")))
166    ///     .build()?;
167    /// let r = serve_net::testing::respond(&app, b"GET / HTTP/1.1\r\nHost: localhost\r\n\r\n");
168    /// assert_eq!(r.header("permissions-policy"), Some(&b"camera=(self)"[..]));
169    /// # Ok::<(), web_net::BuildError>(())
170    /// ```
171    #[must_use]
172    pub fn permissions_policy(mut self, value: &str) -> Self {
173        self.permissions = Some(value.into());
174        self
175    }
176
177    /// `Cross-Origin-Opener-Policy`.
178    ///
179    /// ```
180    /// use web_net::{App, SecurityHeaders, get};
181    ///
182    /// let app = App::builder()
183    ///     .security_headers(SecurityHeaders::default().opener_policy("same-origin-allow-popups"))
184    ///     .route(get("/", |cx| cx.text(200, "ok")))
185    ///     .build()?;
186    /// let r = serve_net::testing::respond(&app, b"GET / HTTP/1.1\r\nHost: localhost\r\n\r\n");
187    /// assert_eq!(r.header("cross-origin-opener-policy"), Some(&b"same-origin-allow-popups"[..]));
188    /// # Ok::<(), web_net::BuildError>(())
189    /// ```
190    #[must_use]
191    pub fn opener_policy(mut self, value: &str) -> Self {
192        self.coop = Some(value.into());
193        self
194    }
195
196    /// `Strict-Transport-Security`, sent on HTTPS replies only and never to
197    /// a development host. `include_subdomains` commits every subdomain to
198    /// HTTPS too: set it only when they all have it.
199    ///
200    /// ```
201    /// use web_net::{App, SecurityHeaders, get};
202    ///
203    /// let app = App::builder()
204    ///     .host("example.com")
205    ///     .security_headers(SecurityHeaders::default().hsts(63_072_000, true))
206    ///     .route(get("/", |cx| cx.text(200, "ok")))
207    ///     .build()?;
208    /// let r = serve_net::testing::TestRequest::new(b"GET / HTTP/1.1\r\nHost: example.com\r\n\r\n")
209    ///     .scheme(serve_net::Scheme::Https)
210    ///     .send(&app);
211    /// assert_eq!(r.header("strict-transport-security"), Some(&b"max-age=63072000; includeSubDomains"[..]));
212    /// # Ok::<(), web_net::BuildError>(())
213    /// ```
214    #[must_use]
215    pub fn hsts(mut self, max_age: u64, include_subdomains: bool) -> Self {
216        let mut v = format!("max-age={max_age}");
217        if include_subdomains {
218            v.push_str("; includeSubDomains");
219        }
220        self.hsts = Some(v.into());
221        self
222    }
223
224    /// No `Strict-Transport-Security`.
225    ///
226    /// ```
227    /// use web_net::{App, SecurityHeaders, get};
228    ///
229    /// let app = App::builder()
230    ///     .host("example.com")
231    ///     .security_headers(SecurityHeaders::default().no_hsts())
232    ///     .route(get("/", |cx| cx.text(200, "ok")))
233    ///     .build()?;
234    /// let r = serve_net::testing::TestRequest::new(b"GET / HTTP/1.1\r\nHost: example.com\r\n\r\n")
235    ///     .scheme(serve_net::Scheme::Https)
236    ///     .send(&app);
237    /// assert_eq!(r.header("strict-transport-security"), None);
238    /// # Ok::<(), web_net::BuildError>(())
239    /// ```
240    #[must_use]
241    pub fn no_hsts(mut self) -> Self {
242        self.hsts = None;
243        self
244    }
245
246    /// The fields to send, in order, for a reply over HTTPS or not.
247    pub(crate) fn fields(&self, https: bool) -> impl Iterator<Item = (&'static str, &str)> {
248        [
249            ("content-security-policy", self.csp.as_deref()),
250            ("x-frame-options", self.frame.as_deref()),
251            ("referrer-policy", self.referrer.as_deref()),
252            ("permissions-policy", self.permissions.as_deref()),
253            ("cross-origin-opener-policy", self.coop.as_deref()),
254            (
255                "strict-transport-security",
256                self.hsts.as_deref().filter(|_| https),
257            ),
258        ]
259        .into_iter()
260        .filter_map(|(n, v)| v.map(|v| (n, v)))
261    }
262
263    /// Whether every value is visible ASCII (checked at build).
264    pub(crate) fn valid(&self) -> bool {
265        self.fields(true)
266            .all(|(_, v)| !v.is_empty() && v.bytes().all(|b| (0x20..0x7f).contains(&b)))
267    }
268}
269
270#[cfg(test)]
271mod tests {
272    use super::*;
273
274    #[test]
275    fn test_hsts_only_on_https() {
276        let p = SecurityHeaders::default();
277        assert!(
278            p.fields(false)
279                .all(|(n, _)| n != "strict-transport-security")
280        );
281        assert!(
282            p.fields(true)
283                .any(|(n, _)| n == "strict-transport-security")
284        );
285        assert_eq!(SecurityHeaders::none().fields(true).count(), 0);
286        assert!(p.valid());
287        assert!(!SecurityHeaders::default().referrer_policy("a\nb").valid());
288    }
289}