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.
29pub const DEFAULT_CSP: &str = "default-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'";
30
31impl Default for SecurityHeaders {
32    fn default() -> Self {
33        Self {
34            csp: Some(DEFAULT_CSP.into()),
35            frame: Some("DENY".into()),
36            referrer: Some("strict-origin-when-cross-origin".into()),
37            permissions: Some("camera=(), microphone=(), geolocation=(), payment=()".into()),
38            coop: Some("same-origin".into()),
39            hsts: Some("max-age=31536000".into()),
40        }
41    }
42}
43
44impl SecurityHeaders {
45    /// No security headers at all (`x-content-type-options: nosniff` is
46    /// still sent: it has no cost and no downside).
47    #[must_use]
48    pub fn none() -> Self {
49        Self {
50            csp: None,
51            frame: None,
52            referrer: None,
53            permissions: None,
54            coop: None,
55            hsts: None,
56        }
57    }
58
59    /// `Content-Security-Policy`.
60    #[must_use]
61    pub fn content_security_policy(mut self, value: &str) -> Self {
62        self.csp = Some(value.into());
63        self
64    }
65
66    /// No `Content-Security-Policy`.
67    #[must_use]
68    pub fn no_content_security_policy(mut self) -> Self {
69        self.csp = None;
70        self
71    }
72
73    /// `X-Frame-Options` (`DENY` by default; `SAMEORIGIN` to allow the site's
74    /// own frames).
75    #[must_use]
76    pub fn frame_options(mut self, value: &str) -> Self {
77        self.frame = Some(value.into());
78        self
79    }
80
81    /// `Referrer-Policy`.
82    #[must_use]
83    pub fn referrer_policy(mut self, value: &str) -> Self {
84        self.referrer = Some(value.into());
85        self
86    }
87
88    /// `Permissions-Policy`.
89    #[must_use]
90    pub fn permissions_policy(mut self, value: &str) -> Self {
91        self.permissions = Some(value.into());
92        self
93    }
94
95    /// `Cross-Origin-Opener-Policy`.
96    #[must_use]
97    pub fn opener_policy(mut self, value: &str) -> Self {
98        self.coop = Some(value.into());
99        self
100    }
101
102    /// `Strict-Transport-Security`, sent on HTTPS replies only and never to
103    /// a development host. `include_subdomains` commits every subdomain to
104    /// HTTPS too: set it only when they all have it.
105    #[must_use]
106    pub fn hsts(mut self, max_age: u64, include_subdomains: bool) -> Self {
107        let mut v = format!("max-age={max_age}");
108        if include_subdomains {
109            v.push_str("; includeSubDomains");
110        }
111        self.hsts = Some(v.into());
112        self
113    }
114
115    /// No `Strict-Transport-Security`.
116    #[must_use]
117    pub fn no_hsts(mut self) -> Self {
118        self.hsts = None;
119        self
120    }
121
122    /// The fields to send, in order, for a reply over HTTPS or not.
123    pub(crate) fn fields(&self, https: bool) -> impl Iterator<Item = (&'static str, &str)> {
124        [
125            ("content-security-policy", self.csp.as_deref()),
126            ("x-frame-options", self.frame.as_deref()),
127            ("referrer-policy", self.referrer.as_deref()),
128            ("permissions-policy", self.permissions.as_deref()),
129            ("cross-origin-opener-policy", self.coop.as_deref()),
130            (
131                "strict-transport-security",
132                self.hsts.as_deref().filter(|_| https),
133            ),
134        ]
135        .into_iter()
136        .filter_map(|(n, v)| v.map(|v| (n, v)))
137    }
138
139    /// Whether every value is visible ASCII (checked at build).
140    pub(crate) fn valid(&self) -> bool {
141        self.fields(true)
142            .all(|(_, v)| !v.is_empty() && v.bytes().all(|b| (0x20..0x7f).contains(&b)))
143    }
144}
145
146#[cfg(test)]
147mod tests {
148    use super::*;
149
150    #[test]
151    fn test_hsts_only_on_https() {
152        let p = SecurityHeaders::default();
153        assert!(
154            p.fields(false)
155                .all(|(n, _)| n != "strict-transport-security")
156        );
157        assert!(
158            p.fields(true)
159                .any(|(n, _)| n == "strict-transport-security")
160        );
161        assert_eq!(SecurityHeaders::none().fields(true).count(), 0);
162        assert!(p.valid());
163        assert!(!SecurityHeaders::default().referrer_policy("a\nb").valid());
164    }
165}