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}