rskit_security/http/
headers.rs1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
23#[non_exhaustive]
24pub enum TransportSecurity {
25 #[default]
27 HttpsOnly,
28 AllowInsecureLocal,
30}
31
32#[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 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 #[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 #[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 #[must_use]
96 pub fn with_permissions_policy(mut self, value: Option<String>) -> Self {
97 self.permissions_policy = value;
98 self
99 }
100
101 #[must_use]
103 pub fn with_referrer_policy(mut self, value: Option<String>) -> Self {
104 self.referrer_policy = value;
105 self
106 }
107
108 #[must_use]
110 pub fn with_frame_options(mut self, value: Option<String>) -> Self {
111 self.frame_options = value;
112 self
113 }
114
115 #[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 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}