http_security_headers/policy/
hsts.rs

1//! HTTP Strict Transport Security (HSTS) configuration.
2//!
3//! HSTS tells browsers to only connect to the site over HTTPS, preventing
4//! protocol downgrade attacks and cookie hijacking.
5
6use crate::error::{Error, Result};
7use std::time::Duration;
8
9/// HTTP Strict Transport Security (HSTS) policy.
10///
11/// # Examples
12///
13/// ```
14/// use http_security_headers::StrictTransportSecurity;
15/// use std::time::Duration;
16///
17/// // One year HSTS with subdomains
18/// let hsts = StrictTransportSecurity::new(Duration::from_secs(31536000))
19///     .include_subdomains(true);
20///
21/// // Custom configuration
22/// let hsts = StrictTransportSecurity::new(Duration::from_secs(86400))
23///     .include_subdomains(false)
24///     .preload(true);
25/// ```
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct StrictTransportSecurity {
28    max_age: Duration,
29    include_subdomains: bool,
30    preload: bool,
31}
32
33impl StrictTransportSecurity {
34    /// Creates a new HSTS policy with the specified max-age.
35    ///
36    /// # Arguments
37    ///
38    /// * `max_age` - The time, in seconds, that the browser should remember to only access the site using HTTPS.
39    ///
40    /// # Examples
41    ///
42    /// ```
43    /// use http_security_headers::StrictTransportSecurity;
44    /// use std::time::Duration;
45    ///
46    /// let hsts = StrictTransportSecurity::new(Duration::from_secs(31536000)); // 1 year
47    /// ```
48    pub fn new(max_age: Duration) -> Self {
49        Self {
50            max_age,
51            include_subdomains: false,
52            preload: false,
53        }
54    }
55
56    /// Sets whether the rule applies to all subdomains.
57    ///
58    /// When enabled, this rule applies to all of the site's subdomains as well.
59    pub fn include_subdomains(mut self, include: bool) -> Self {
60        self.include_subdomains = include;
61        self
62    }
63
64    /// Sets whether the site wishes to be included in the HSTS preload list.
65    ///
66    /// Note: Before enabling preload, ensure your site meets the preload list requirements:
67    /// <https://hstspreload.org/>
68    pub fn preload(mut self, preload: bool) -> Self {
69        self.preload = preload;
70        self
71    }
72
73    /// Gets the max-age duration.
74    pub fn max_age(&self) -> Duration {
75        self.max_age
76    }
77
78    /// Returns whether subdomains are included.
79    pub fn includes_subdomains(&self) -> bool {
80        self.include_subdomains
81    }
82
83    /// Returns whether preload is enabled.
84    pub fn is_preload(&self) -> bool {
85        self.preload
86    }
87
88    /// Converts the policy to its header value string.
89    pub fn to_header_value(&self) -> Result<String> {
90        let max_age_secs = self.max_age.as_secs();
91
92        if max_age_secs == 0 {
93            return Err(Error::InvalidHsts(
94                "max-age must be greater than 0".to_string(),
95            ));
96        }
97
98        if self.preload {
99            if !self.include_subdomains {
100                return Err(Error::InvalidHsts(
101                    "preload requires includeSubDomains to be enabled".to_string(),
102                ));
103            }
104
105            if max_age_secs < 31_536_000 {
106                return Err(Error::InvalidHsts(
107                    "preload requires max-age to be at least 31536000 seconds (1 year)"
108                        .to_string(),
109                ));
110            }
111        }
112
113        let mut value = format!("max-age={}", max_age_secs);
114
115        if self.include_subdomains {
116            value.push_str("; includeSubDomains");
117        }
118
119        if self.preload {
120            value.push_str("; preload");
121        }
122
123        Ok(value)
124    }
125
126    /// Parses an HSTS policy from a header value string.
127    ///
128    /// # Examples
129    ///
130    /// ```
131    /// use http_security_headers::StrictTransportSecurity;
132    ///
133    /// let hsts = StrictTransportSecurity::parse("max-age=31536000; includeSubDomains").unwrap();
134    /// ```
135    pub fn parse(value: &str) -> Result<Self> {
136        let mut max_age = None;
137        let mut include_subdomains = false;
138        let mut preload = false;
139
140        for directive in value.split(';').map(|s| s.trim()) {
141            if directive.starts_with("max-age=") {
142                let age_str = directive.trim_start_matches("max-age=");
143                let age_secs = age_str.parse::<u64>().map_err(|_| {
144                    Error::InvalidHsts(format!("Invalid max-age value: '{}'", age_str))
145                })?;
146                max_age = Some(Duration::from_secs(age_secs));
147            } else if directive.eq_ignore_ascii_case("includeSubDomains") {
148                include_subdomains = true;
149            } else if directive.eq_ignore_ascii_case("preload") {
150                preload = true;
151            }
152        }
153
154        let max_age = max_age.ok_or_else(|| Error::InvalidHsts("Missing max-age directive".to_string()))?;
155
156        if preload && !include_subdomains {
157            return Err(Error::InvalidHsts(
158                "preload requires the includeSubDomains directive".to_string(),
159            ));
160        }
161
162        if preload && max_age.as_secs() < 31_536_000 {
163            return Err(Error::InvalidHsts(
164                "preload requires max-age to be at least 31536000 seconds (1 year)".to_string(),
165            ));
166        }
167
168        Ok(Self {
169            max_age,
170            include_subdomains,
171            preload,
172        })
173    }
174}
175
176impl std::fmt::Display for StrictTransportSecurity {
177    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
178        write!(f, "{}", self.to_header_value().unwrap_or_default())
179    }
180}
181
182#[cfg(test)]
183mod tests {
184    use super::*;
185
186    #[test]
187    fn test_new() {
188        let hsts = StrictTransportSecurity::new(Duration::from_secs(31536000));
189        assert_eq!(hsts.max_age(), Duration::from_secs(31536000));
190        assert!(!hsts.includes_subdomains());
191        assert!(!hsts.is_preload());
192    }
193
194    #[test]
195    fn test_builder() {
196        let hsts = StrictTransportSecurity::new(Duration::from_secs(31536000))
197            .include_subdomains(true)
198            .preload(true);
199
200        assert_eq!(hsts.max_age(), Duration::from_secs(31536000));
201        assert!(hsts.includes_subdomains());
202        assert!(hsts.is_preload());
203    }
204
205    #[test]
206    fn test_to_header_value() {
207        let hsts = StrictTransportSecurity::new(Duration::from_secs(31536000));
208        assert_eq!(hsts.to_header_value().unwrap(), "max-age=31536000");
209
210        let hsts = StrictTransportSecurity::new(Duration::from_secs(31536000))
211            .include_subdomains(true);
212        assert_eq!(
213            hsts.to_header_value().unwrap(),
214            "max-age=31536000; includeSubDomains"
215        );
216
217        let hsts = StrictTransportSecurity::new(Duration::from_secs(31536000))
218            .include_subdomains(true)
219            .preload(true);
220        assert_eq!(
221            hsts.to_header_value().unwrap(),
222            "max-age=31536000; includeSubDomains; preload"
223        );
224    }
225
226    #[test]
227    fn test_to_header_value_zero_max_age() {
228        let hsts = StrictTransportSecurity::new(Duration::from_secs(0));
229        assert!(hsts.to_header_value().is_err());
230    }
231
232    #[test]
233    fn test_to_header_value_invalid_preload() {
234        let hsts = StrictTransportSecurity::new(Duration::from_secs(31536000)).preload(true);
235        assert!(hsts.to_header_value().is_err());
236
237        let hsts = StrictTransportSecurity::new(Duration::from_secs(60))
238            .include_subdomains(true)
239            .preload(true);
240        assert!(hsts.to_header_value().is_err());
241    }
242
243    #[test]
244    fn test_parse() {
245        let hsts = StrictTransportSecurity::parse("max-age=31536000").unwrap();
246        assert_eq!(hsts.max_age(), Duration::from_secs(31536000));
247        assert!(!hsts.includes_subdomains());
248        assert!(!hsts.is_preload());
249
250        let hsts =
251            StrictTransportSecurity::parse("max-age=31536000; includeSubDomains").unwrap();
252        assert!(hsts.includes_subdomains());
253
254        let hsts = StrictTransportSecurity::parse("max-age=31536000; includeSubDomains; preload")
255            .unwrap();
256        assert!(hsts.includes_subdomains());
257        assert!(hsts.is_preload());
258    }
259
260    #[test]
261    fn test_parse_invalid() {
262        assert!(StrictTransportSecurity::parse("invalid").is_err());
263        assert!(StrictTransportSecurity::parse("max-age=invalid").is_err());
264        assert!(StrictTransportSecurity::parse("").is_err());
265    }
266
267    #[test]
268    fn test_parse_invalid_preload() {
269        assert!(StrictTransportSecurity::parse("max-age=31536000; preload").is_err());
270        assert!(StrictTransportSecurity::parse("max-age=100; includeSubDomains; preload").is_err());
271    }
272
273    #[test]
274    fn test_display() {
275        let hsts = StrictTransportSecurity::new(Duration::from_secs(31536000))
276            .include_subdomains(true);
277        assert_eq!(hsts.to_string(), "max-age=31536000; includeSubDomains");
278    }
279}