Skip to main content

rama_http_headers/common/
strict_transport_security.rs

1use std::fmt;
2use std::time::Duration;
3
4use rama_http_types::{HeaderName, HeaderValue};
5
6use crate::util::{self, IterExt, Seconds};
7use crate::{Error, HeaderDecode, HeaderEncode, TypedHeader};
8
9/// `StrictTransportSecurity` header, defined in [RFC6797](https://tools.ietf.org/html/rfc6797)
10///
11/// This specification defines a mechanism enabling web sites to declare
12/// themselves accessible only via secure connections and/or for users to be
13/// able to direct their user agent(s) to interact with given sites only over
14/// secure connections.  This overall policy is referred to as HTTP Strict
15/// Transport Security (HSTS).  The policy is declared by web sites via the
16/// Strict-Transport-Security HTTP response header field and/or by other means,
17/// such as user agent configuration, for example.
18///
19/// # ABNF
20///
21/// ```text
22///      [ directive ]  *( ";" [ directive ] )
23///
24///      directive                 = directive-name [ "=" directive-value ]
25///      directive-name            = token
26///      directive-value           = token | quoted-string
27///
28/// ```
29///
30/// # Example values
31///
32/// * `max-age=31536000`
33/// * `max-age=15768000 ; includeSubdomains`
34/// * `max-age=31536000; includeSubDomains; preload`
35///
36/// # Example
37///
38/// ```
39/// use std::time::Duration;
40/// use rama_http_headers::StrictTransportSecurity;
41///
42/// let sts = StrictTransportSecurity::including_subdomains_for_max_seconds(31_536_000)
43///     .with_preload(true);
44/// ```
45#[derive(Clone, Debug, PartialEq)]
46pub struct StrictTransportSecurity {
47    /// Signals the UA that the HSTS Policy applies to this HSTS Host as well as
48    /// any subdomains of the host's domain name.
49    include_subdomains: bool,
50
51    /// Signals that the host is (or wishes to be) on the Chromium/Mozilla
52    /// HSTS preload list. Not part of RFC 6797 but is the convention
53    /// required for <https://hstspreload.org> eligibility.
54    preload: bool,
55
56    /// Specifies the number of seconds, after the reception of the STS header
57    /// field, during which the UA regards the host (from whom the message was
58    /// received) as a Known HSTS Host.
59    max_age: Seconds,
60}
61
62impl StrictTransportSecurity {
63    // NOTE: The two constructors exist to make a user *have* to decide if
64    // subdomains can be included or not, instead of forgetting due to an
65    // incorrect assumption about a default.
66
67    /// Create an STS header that includes subdomains
68    #[must_use]
69    pub fn including_subdomains_for_max_seconds(max_age: u64) -> Self {
70        Self {
71            max_age: Seconds::new(max_age),
72            include_subdomains: true,
73            preload: false,
74        }
75    }
76
77    /// Create an STS header that includes subdomains
78    ///
79    /// The given [`Duration`] is rounded by ignoring any sub nano seconds.
80    /// Use [`Self::including_subdomains_for_max_duration`] in case you want to make
81    /// that a fallible case instead.
82    #[must_use]
83    pub fn including_subdomains_for_max_duration_rounded(dur: Duration) -> Self {
84        Self {
85            max_age: Seconds::from_duration_rounded(dur),
86            include_subdomains: true,
87            preload: false,
88        }
89    }
90
91    /// Try to create a STS header that includes subdomains
92    ///
93    /// # Error
94    ///
95    /// Errors in case the given [`Duration`] contains sub nano seconds,
96    /// use [`Self::including_subdomains_for_max_seconds`] or
97    /// [`Self::including_subdomains_for_max_duration_rounded`] for a infallible constructor.
98    #[must_use]
99    pub fn including_subdomains_for_max_duration(dur: Duration) -> Option<Self> {
100        Seconds::try_from_duration(dur).map(|max_age| Self {
101            max_age,
102            include_subdomains: true,
103            preload: false,
104        })
105    }
106
107    /// Create an STS header that excludes subdomains
108    #[must_use]
109    pub fn excluding_subdomains_for_max_seconds(max_age: u64) -> Self {
110        Self {
111            max_age: Seconds::new(max_age),
112            include_subdomains: false,
113            preload: false,
114        }
115    }
116
117    /// Create an STS header that excludes subdomains
118    ///
119    /// The given [`Duration`] is rounded by ignoring any sub nano seconds.
120    /// Use [`Self::excluding_subdomains_for_max_duration`] in case you want to make
121    /// that a fallible case instead.
122    #[must_use]
123    pub fn excluding_subdomains_for_max_duration_rounded(dur: Duration) -> Self {
124        Self {
125            max_age: Seconds::from_duration_rounded(dur),
126            include_subdomains: false,
127            preload: false,
128        }
129    }
130
131    /// Try to create a STS header that excludes subdomains
132    ///
133    /// # Error
134    ///
135    /// Errors in case the given [`Duration`] contains sub nano seconds,
136    /// use [`Self::excluding_subdomains_for_max_seconds`] or
137    /// [`Self::excluding_subdomains_for_max_duration_rounded`] for a infallible constructor.
138    #[must_use]
139    pub fn excluding_subdomains_for_max_duration(dur: Duration) -> Option<Self> {
140        Seconds::try_from_duration(dur).map(|max_age| Self {
141            max_age,
142            include_subdomains: false,
143            preload: false,
144        })
145    }
146
147    rama_utils::macros::generate_set_and_with! {
148        /// Mark this STS header as `preload`-eligible.
149        ///
150        /// The `preload` directive is a Chromium/Mozilla extension required for
151        /// [HSTS preload list](https://hstspreload.org) eligibility — sites
152        /// listed there get HSTS protection on the user's *very first* visit.
153        /// Per the preload list submission rules `preload` is only meaningful
154        /// alongside `max-age` ≥ 31536000 and `includeSubDomains`; this builder
155        /// does not enforce that, but callers should set both.
156        pub fn preload(mut self, preload: bool) -> Self {
157            self.preload = preload;
158            self
159        }
160    }
161
162    // getters
163
164    /// Get whether this should include subdomains.
165    #[must_use]
166    pub fn include_subdomains(&self) -> bool {
167        self.include_subdomains
168    }
169
170    /// Get whether the `preload` directive is set.
171    #[must_use]
172    pub fn preload(&self) -> bool {
173        self.preload
174    }
175
176    /// Get the max-age.
177    #[must_use]
178    pub fn max_age(&self) -> Duration {
179        self.max_age.into()
180    }
181}
182
183enum Directive {
184    MaxAge(u64),
185    IncludeSubdomains,
186    Preload,
187    Unknown,
188}
189
190fn from_str(s: &str) -> Result<StrictTransportSecurity, Error> {
191    s.split(';')
192        .map(str::trim)
193        .map(|sub| {
194            if sub.eq_ignore_ascii_case("includeSubdomains") {
195                Some(Directive::IncludeSubdomains)
196            } else if sub.eq_ignore_ascii_case("preload") {
197                Some(Directive::Preload)
198            } else {
199                let mut sub = sub.splitn(2, '=');
200                match (sub.next(), sub.next()) {
201                    (Some(left), Some(right)) if left.trim().eq_ignore_ascii_case("max-age") => {
202                        right
203                            .trim()
204                            .trim_matches('"')
205                            .parse()
206                            .ok()
207                            .map(Directive::MaxAge)
208                    }
209                    _ => Some(Directive::Unknown),
210                }
211            }
212        })
213        .try_fold((None, None, None), |res, dir| match (res, dir) {
214            ((None, sub, pre), Some(Directive::MaxAge(age))) => Some((Some(age), sub, pre)),
215            ((age, None, pre), Some(Directive::IncludeSubdomains)) => Some((age, Some(()), pre)),
216            ((age, sub, None), Some(Directive::Preload)) => Some((age, sub, Some(()))),
217            ((Some(_), _, _), Some(Directive::MaxAge(_)))
218            | ((_, Some(_), _), Some(Directive::IncludeSubdomains))
219            | ((_, _, Some(_)), Some(Directive::Preload))
220            | (_, None) => None,
221            (res, _) => Some(res),
222        })
223        .and_then(|res| match res {
224            (Some(age), sub, pre) => Some(StrictTransportSecurity {
225                max_age: Seconds::new(age),
226                include_subdomains: sub.is_some(),
227                preload: pre.is_some(),
228            }),
229            _ => None,
230        })
231        .ok_or_else(Error::invalid)
232}
233
234impl TypedHeader for StrictTransportSecurity {
235    fn name() -> &'static HeaderName {
236        &::rama_http_types::header::STRICT_TRANSPORT_SECURITY
237    }
238}
239
240impl HeaderDecode for StrictTransportSecurity {
241    fn decode<'i, I: Iterator<Item = &'i HeaderValue>>(values: &mut I) -> Result<Self, Error> {
242        values
243            .just_one()
244            .and_then(|v| v.to_str().ok())
245            .map(from_str)
246            .unwrap_or_else(|| Err(Error::invalid()))
247    }
248}
249
250impl HeaderEncode for StrictTransportSecurity {
251    fn encode<E: Extend<HeaderValue>>(&self, values: &mut E) {
252        struct Adapter<'a>(&'a StrictTransportSecurity);
253
254        impl fmt::Display for Adapter<'_> {
255            fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
256                write!(f, "max-age={}", self.0.max_age)?;
257                if self.0.include_subdomains {
258                    f.write_str("; includeSubDomains")?;
259                }
260                if self.0.preload {
261                    f.write_str("; preload")?;
262                }
263                Ok(())
264            }
265        }
266
267        values.extend(::std::iter::once(util::fmt(Adapter(self))));
268    }
269}
270
271#[cfg(test)]
272mod tests {
273    use super::super::test_decode;
274    use super::*;
275
276    #[test]
277    fn test_parse_max_age() {
278        let h = test_decode::<StrictTransportSecurity>(&["max-age=31536000"]).unwrap();
279        assert_eq!(
280            h,
281            StrictTransportSecurity {
282                include_subdomains: false,
283                preload: false,
284                max_age: Seconds::new(31536000),
285            }
286        );
287    }
288
289    #[test]
290    fn test_parse_max_age_no_value() {
291        assert_eq!(test_decode::<StrictTransportSecurity>(&["max-age"]), None,);
292    }
293
294    #[test]
295    fn test_parse_quoted_max_age() {
296        let h = test_decode::<StrictTransportSecurity>(&["max-age=\"31536000\""]).unwrap();
297        assert_eq!(
298            h,
299            StrictTransportSecurity {
300                include_subdomains: false,
301                preload: false,
302                max_age: Seconds::new(31536000),
303            }
304        );
305    }
306
307    #[test]
308    fn test_parse_spaces_max_age() {
309        let h = test_decode::<StrictTransportSecurity>(&["max-age = 31536000"]).unwrap();
310        assert_eq!(
311            h,
312            StrictTransportSecurity {
313                include_subdomains: false,
314                preload: false,
315                max_age: Seconds::new(31536000),
316            }
317        );
318    }
319
320    #[test]
321    fn test_parse_include_subdomains() {
322        let h = test_decode::<StrictTransportSecurity>(&["max-age=15768000 ; includeSubDomains"])
323            .unwrap();
324        assert_eq!(
325            h,
326            StrictTransportSecurity {
327                include_subdomains: true,
328                preload: false,
329                max_age: Seconds::new(15768000),
330            }
331        );
332    }
333
334    #[test]
335    fn test_parse_no_max_age() {
336        assert_eq!(
337            test_decode::<StrictTransportSecurity>(&["includeSubdomains"]),
338            None,
339        );
340    }
341
342    #[test]
343    fn test_parse_max_age_nan() {
344        assert_eq!(
345            test_decode::<StrictTransportSecurity>(&["max-age = izzy"]),
346            None,
347        );
348    }
349
350    #[test]
351    fn test_parse_duplicate_directives() {
352        assert_eq!(
353            test_decode::<StrictTransportSecurity>(&["max-age=1; max-age=2"]),
354            None,
355        );
356    }
357
358    #[test]
359    fn test_parse_preload() {
360        for raw in [
361            "max-age=31536000; includeSubDomains; preload",
362            "max-age=31536000; includeSubDomains; Preload",
363            "max-age=31536000; includeSubDomains; PRELOAD",
364        ] {
365            let h = test_decode::<StrictTransportSecurity>(&[raw]).unwrap_or_else(|| {
366                panic!("failed to decode {raw}");
367            });
368            assert_eq!(
369                h,
370                StrictTransportSecurity {
371                    include_subdomains: true,
372                    preload: true,
373                    max_age: Seconds::new(31536000),
374                }
375            );
376        }
377    }
378
379    #[test]
380    fn test_parse_preload_without_subdomains() {
381        let h = test_decode::<StrictTransportSecurity>(&["max-age=31536000; preload"]).unwrap();
382        assert_eq!(
383            h,
384            StrictTransportSecurity {
385                include_subdomains: false,
386                preload: true,
387                max_age: Seconds::new(31536000),
388            }
389        );
390    }
391
392    #[test]
393    fn test_parse_duplicate_preload_rejected() {
394        assert_eq!(
395            test_decode::<StrictTransportSecurity>(&["max-age=1; preload; preload"]),
396            None,
397        );
398    }
399
400    #[test]
401    fn test_encode_canonical_order() {
402        let sts = StrictTransportSecurity::including_subdomains_for_max_seconds(31_536_000)
403            .with_preload(true);
404        let map = super::super::test_encode(sts);
405        let raw = map
406            .get(StrictTransportSecurity::name())
407            .expect("header set")
408            .to_str()
409            .unwrap()
410            .to_owned();
411        assert_eq!(raw, "max-age=31536000; includeSubDomains; preload");
412    }
413
414    #[test]
415    fn test_encode_preload_excluding_subdomains() {
416        let sts = StrictTransportSecurity::excluding_subdomains_for_max_seconds(31_536_000)
417            .with_preload(true);
418        let map = super::super::test_encode(sts);
419        let raw = map
420            .get(StrictTransportSecurity::name())
421            .expect("header set")
422            .to_str()
423            .unwrap()
424            .to_owned();
425        assert_eq!(raw, "max-age=31536000; preload");
426    }
427
428    #[test]
429    fn test_preload_round_trip_idempotent() {
430        let sts = StrictTransportSecurity::including_subdomains_for_max_seconds(31_536_000)
431            .with_preload(true);
432        let map = super::super::test_encode(sts.clone());
433        let raw = map
434            .get(StrictTransportSecurity::name())
435            .expect("header set")
436            .to_str()
437            .unwrap()
438            .to_owned();
439        let parsed = test_decode::<StrictTransportSecurity>(&[raw.as_str()])
440            .expect("re-decode of canonical form");
441        assert_eq!(parsed, sts);
442    }
443}
444
445//bench_header!(bench, StrictTransportSecurity, { vec![b"max-age=15768000 ; includeSubDomains".to_vec()] });