Skip to main content

rskit_security/http/
headers.rs

1//! Secure-by-default HTTP response header policy.
2
3use http::{
4    HeaderName, HeaderValue,
5    header::{
6        CONTENT_SECURITY_POLICY, REFERRER_POLICY, STRICT_TRANSPORT_SECURITY,
7        X_CONTENT_TYPE_OPTIONS, X_FRAME_OPTIONS,
8    },
9};
10use rskit_errors::{AppError, AppResult};
11
12const HSTS_HEADER_VALUE: &str = "max-age=63072000; includeSubDomains; preload";
13const CSP_HEADER_VALUE: &str =
14    "default-src 'self'; base-uri 'self'; frame-ancestors 'none'; object-src 'none'";
15const PERMISSIONS_POLICY: HeaderName = HeaderName::from_static("permissions-policy");
16const REFERRER_POLICY_VALUE: &str = "strict-origin-when-cross-origin";
17const PERMISSIONS_POLICY_VALUE: &str = "accelerometer=(), camera=(), geolocation=(), microphone=()";
18const X_CONTENT_TYPE_OPTIONS_VALUE: &str = "nosniff";
19const X_FRAME_OPTIONS_VALUE: &str = "DENY";
20
21/// Transport security mode used when applying response headers.
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
23#[non_exhaustive]
24pub enum TransportSecurity {
25    /// Production mode: HTTPS is required and HSTS is emitted.
26    #[default]
27    HttpsOnly,
28    /// Local/insecure mode: HSTS is omitted while the remaining headers still apply.
29    AllowInsecureLocal,
30}
31
32/// Secure-by-default response header configuration.
33#[derive(Debug, Clone)]
34pub struct SecurityHeadersConfig {
35    transport_security: TransportSecurity,
36    content_security_policy: Option<String>,
37    permissions_policy: Option<String>,
38    referrer_policy: Option<String>,
39    frame_options: Option<String>,
40    content_type_options: Option<String>,
41}
42
43impl Default for SecurityHeadersConfig {
44    fn default() -> Self {
45        Self {
46            transport_security: TransportSecurity::HttpsOnly,
47            content_security_policy: Some(CSP_HEADER_VALUE.to_string()),
48            permissions_policy: Some(PERMISSIONS_POLICY_VALUE.to_string()),
49            referrer_policy: Some(REFERRER_POLICY_VALUE.to_string()),
50            frame_options: Some(X_FRAME_OPTIONS_VALUE.to_string()),
51            content_type_options: Some(X_CONTENT_TYPE_OPTIONS_VALUE.to_string()),
52        }
53    }
54}
55
56impl SecurityHeadersConfig {
57    /// Validate the configuration.
58    ///
59    /// # Errors
60    /// Returns an error when the configuration cannot be safely applied.
61    pub fn validate(&self) -> AppResult<()> {
62        if matches!(
63            self.transport_security,
64            TransportSecurity::AllowInsecureLocal
65        ) && self.content_security_policy.is_none()
66            && self.permissions_policy.is_none()
67            && self.referrer_policy.is_none()
68            && self.frame_options.is_none()
69            && self.content_type_options.is_none()
70        {
71            return Err(AppError::invalid_input(
72                "security_headers",
73                "at least one security header must remain enabled",
74            ));
75        }
76
77        Ok(())
78    }
79
80    /// Select the transport security mode.
81    #[must_use]
82    pub const fn with_transport_security(mut self, transport_security: TransportSecurity) -> Self {
83        self.transport_security = transport_security;
84        self
85    }
86
87    /// Override the Content Security Policy header. Pass `None` to disable it.
88    #[must_use]
89    pub fn with_content_security_policy(mut self, value: Option<String>) -> Self {
90        self.content_security_policy = value;
91        self
92    }
93
94    /// Override the Permissions Policy header. Pass `None` to disable it.
95    #[must_use]
96    pub fn with_permissions_policy(mut self, value: Option<String>) -> Self {
97        self.permissions_policy = value;
98        self
99    }
100
101    /// Override the Referrer Policy header. Pass `None` to disable it.
102    #[must_use]
103    pub fn with_referrer_policy(mut self, value: Option<String>) -> Self {
104        self.referrer_policy = value;
105        self
106    }
107
108    /// Override the X-Frame-Options header. Pass `None` to disable it.
109    #[must_use]
110    pub fn with_frame_options(mut self, value: Option<String>) -> Self {
111        self.frame_options = value;
112        self
113    }
114
115    /// Override the X-Content-Type-Options header. Pass `None` to disable it.
116    #[must_use]
117    pub fn with_content_type_options(mut self, value: Option<String>) -> Self {
118        self.content_type_options = value;
119        self
120    }
121
122    /// Build validated header pairs for transport adapters.
123    ///
124    /// # Errors
125    /// Returns an error when a configured header value is invalid.
126    pub fn header_pairs(&self) -> AppResult<Vec<(HeaderName, HeaderValue)>> {
127        self.validate()?;
128
129        let mut headers = Vec::new();
130        if matches!(self.transport_security, TransportSecurity::HttpsOnly) {
131            headers.push((
132                STRICT_TRANSPORT_SECURITY,
133                HeaderValue::from_static(HSTS_HEADER_VALUE),
134            ));
135        }
136        if let Some(value) = &self.content_security_policy {
137            headers.push((
138                CONTENT_SECURITY_POLICY,
139                header_value(&CONTENT_SECURITY_POLICY, value)?,
140            ));
141        }
142        if let Some(value) = &self.permissions_policy {
143            headers.push((
144                PERMISSIONS_POLICY,
145                header_value(&PERMISSIONS_POLICY, value)?,
146            ));
147        }
148        if let Some(value) = &self.referrer_policy {
149            headers.push((REFERRER_POLICY, header_value(&REFERRER_POLICY, value)?));
150        }
151        if let Some(value) = &self.frame_options {
152            headers.push((X_FRAME_OPTIONS, header_value(&X_FRAME_OPTIONS, value)?));
153        }
154        if let Some(value) = &self.content_type_options {
155            headers.push((
156                X_CONTENT_TYPE_OPTIONS,
157                header_value(&X_CONTENT_TYPE_OPTIONS, value)?,
158            ));
159        }
160        Ok(headers)
161    }
162}
163
164fn header_value(header: &HeaderName, value: &str) -> AppResult<HeaderValue> {
165    HeaderValue::from_str(value).map_err(|error| {
166        AppError::invalid_input(header.as_str(), format!("invalid header value: {error}"))
167    })
168}
169
170#[cfg(test)]
171mod tests {
172    use super::*;
173
174    fn contains_header(headers: &[(HeaderName, HeaderValue)], name: &HeaderName) -> bool {
175        headers.iter().any(|(header, _)| header == name)
176    }
177
178    #[test]
179    fn default_headers_include_secure_policy_set() {
180        let headers = SecurityHeadersConfig::default().header_pairs().unwrap();
181
182        assert!(contains_header(&headers, &STRICT_TRANSPORT_SECURITY));
183        assert!(contains_header(&headers, &CONTENT_SECURITY_POLICY));
184        assert!(contains_header(&headers, &PERMISSIONS_POLICY));
185        assert!(contains_header(&headers, &REFERRER_POLICY));
186        assert!(contains_header(&headers, &X_FRAME_OPTIONS));
187        assert!(contains_header(&headers, &X_CONTENT_TYPE_OPTIONS));
188    }
189
190    #[test]
191    fn allow_insecure_local_omits_hsts_only() {
192        let headers = SecurityHeadersConfig::default()
193            .with_transport_security(TransportSecurity::AllowInsecureLocal)
194            .header_pairs()
195            .unwrap();
196
197        assert!(!contains_header(&headers, &STRICT_TRANSPORT_SECURITY));
198        assert!(contains_header(&headers, &CONTENT_SECURITY_POLICY));
199    }
200
201    #[test]
202    fn custom_values_are_applied() {
203        let headers = SecurityHeadersConfig::default()
204            .with_content_security_policy(Some("default-src 'none'".to_string()))
205            .with_permissions_policy(Some("camera=()".to_string()))
206            .with_referrer_policy(Some("no-referrer".to_string()))
207            .with_frame_options(Some("SAMEORIGIN".to_string()))
208            .with_content_type_options(Some("nosniff".to_string()))
209            .header_pairs()
210            .unwrap();
211
212        assert!(
213            headers
214                .iter()
215                .any(|(header, value)| *header == CONTENT_SECURITY_POLICY
216                    && value == "default-src 'none'")
217        );
218        assert!(
219            headers
220                .iter()
221                .any(|(header, value)| *header == X_FRAME_OPTIONS && value == "SAMEORIGIN")
222        );
223    }
224
225    #[test]
226    fn rejects_disabling_all_headers_for_insecure_local() {
227        let config = SecurityHeadersConfig::default()
228            .with_transport_security(TransportSecurity::AllowInsecureLocal)
229            .with_content_security_policy(None)
230            .with_permissions_policy(None)
231            .with_referrer_policy(None)
232            .with_frame_options(None)
233            .with_content_type_options(None);
234
235        assert!(config.validate().is_err());
236        assert!(config.header_pairs().is_err());
237    }
238
239    #[test]
240    fn rejects_invalid_header_values() {
241        let result = SecurityHeadersConfig::default()
242            .with_content_security_policy(Some("bad\nvalue".to_string()))
243            .header_pairs();
244
245        assert!(result.is_err());
246    }
247}