1use std::collections::BTreeMap;
37
38#[derive(Debug, Clone, PartialEq, Eq)]
44pub struct HstsConfig {
45 pub max_age: Option<u64>,
46 pub include_subdomains: bool,
47 pub preload: bool,
48}
49
50impl Default for HstsConfig {
51 fn default() -> Self {
52 Self {
55 max_age: Some(31_536_000),
56 include_subdomains: true,
57 preload: false,
58 }
59 }
60}
61
62#[derive(Debug, Clone, PartialEq, Eq)]
66pub struct CspDirective {
67 pub name: String,
68 pub sources: Vec<String>,
69}
70
71impl CspDirective {
72 #[must_use]
74 pub fn new(name: &str, sources: &[&str]) -> Self {
75 Self {
76 name: name.to_owned(),
77 sources: sources.iter().map(|source| (*source).to_owned()).collect(),
78 }
79 }
80}
81
82#[derive(Debug, Clone, PartialEq, Eq, Default)]
92pub struct SecurityHeadersConfig {
93 pub enabled: bool,
94 pub hsts: Option<HstsConfig>,
95 pub csp: Vec<CspDirective>,
96 pub frame_options: Option<String>,
97 pub content_type_options: Option<String>,
98 pub xss_protection: Option<String>,
99 pub referrer_policy: Option<String>,
100 pub permissions_policy: Option<String>,
103 pub custom: BTreeMap<String, String>,
104}
105
106impl SecurityHeadersConfig {
107 #[must_use]
113 pub fn reference_default() -> Self {
114 Self {
115 enabled: true,
116 hsts: Some(HstsConfig::default()),
117 csp: Vec::new(),
118 frame_options: Some("SAMEORIGIN".to_owned()),
119 content_type_options: Some("nosniff".to_owned()),
120 xss_protection: Some("1; mode=block".to_owned()),
121 referrer_policy: Some("strict-origin-when-cross-origin".to_owned()),
122 permissions_policy: Some("geolocation=(), microphone=(), camera=()".to_owned()),
123 custom: BTreeMap::new(),
124 }
125 }
126
127 pub fn validate(&self) -> Result<(), SecurityHeadersError> {
138 for (name, value) in &self.custom {
139 if !is_valid_header_name(name) {
140 return Err(SecurityHeadersError::InvalidName { name: name.clone() });
141 }
142 validate_header_value(value).map_err(|reason| SecurityHeadersError::InvalidValue {
143 name: name.clone(),
144 reason,
145 })?;
146 }
147 for (name, value) in [
148 ("X-Frame-Options", &self.frame_options),
149 ("X-Content-Type-Options", &self.content_type_options),
150 ("X-XSS-Protection", &self.xss_protection),
151 ("Referrer-Policy", &self.referrer_policy),
152 ("Permissions-Policy", &self.permissions_policy),
153 ] {
154 if let Some(value) = value {
155 validate_header_value(value).map_err(|reason| {
156 SecurityHeadersError::InvalidValue {
157 name: name.to_owned(),
158 reason,
159 }
160 })?;
161 }
162 }
163 Ok(())
164 }
165}
166
167#[derive(Debug, Clone, PartialEq, Eq)]
170pub enum SecurityHeadersError {
171 InvalidName {
173 name: String,
175 },
176 InvalidValue {
179 name: String,
181 reason: &'static str,
183 },
184}
185
186impl std::fmt::Display for SecurityHeadersError {
187 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
188 match self {
189 Self::InvalidName { name } => write!(f, "invalid header name: {name}"),
190 Self::InvalidValue { name, reason } => {
191 write!(f, "invalid value for header {name}: {reason}")
192 }
193 }
194 }
195}
196
197impl std::error::Error for SecurityHeadersError {}
198
199#[must_use]
201pub fn is_valid_header_name(name: &str) -> bool {
202 !name.is_empty()
203 && name
204 .chars()
205 .all(|c| matches!(c, '!' | '#' | '$' | '%' | '&' | '\'' | '*' | '+' | '-' | '.' | '^' | '_' | '`' | '|' | '~' | '0'..='9' | 'A'..='Z' | 'a'..='z'))
206}
207
208const MAX_HEADER_VALUE_BYTES: usize = 8192;
209
210pub fn validate_header_value(value: &str) -> Result<String, &'static str> {
217 if value.contains('\r') || value.contains('\n') {
218 return Err("invalid header value contains newline");
219 }
220 if value.len() > MAX_HEADER_VALUE_BYTES {
221 return Err("header value too long");
222 }
223 Ok(value.chars().filter(|c| *c >= ' ' || *c == '\t').collect())
224}
225
226#[must_use]
229pub fn build_csp(csp: &[CspDirective]) -> String {
230 csp.iter()
231 .map(|directive| {
232 if directive.sources.is_empty() {
233 directive.name.clone()
234 } else {
235 format!("{} {}", directive.name, directive.sources.join(" "))
236 }
237 })
238 .collect::<Vec<_>>()
239 .join("; ")
240}
241
242#[must_use]
248pub fn build_hsts(hsts: &HstsConfig) -> Option<String> {
249 let max_age = hsts.max_age?;
250 let mut preload = hsts.preload;
251 let mut include_subdomains = hsts.include_subdomains;
252 if preload {
253 if max_age < 31_536_000 {
254 preload = false;
255 }
256 if !include_subdomains {
257 include_subdomains = true;
258 }
259 }
260 let mut parts = vec![format!("max-age={max_age}")];
261 if include_subdomains {
262 parts.push("includeSubDomains".to_owned());
263 }
264 if preload {
265 parts.push("preload".to_owned());
266 }
267 Some(parts.join("; "))
268}
269
270pub const CLASS_DEFAULT_HEADERS: [(&str, &str); 10] = [
272 ("X-Content-Type-Options", "nosniff"),
273 ("X-Frame-Options", "SAMEORIGIN"),
274 ("X-XSS-Protection", "1; mode=block"),
275 ("Referrer-Policy", "strict-origin-when-cross-origin"),
276 (
277 "Permissions-Policy",
278 "geolocation=(), microphone=(), camera=()",
279 ),
280 ("X-Permitted-Cross-Domain-Policies", "none"),
281 ("X-Download-Options", "noopen"),
282 ("Cross-Origin-Embedder-Policy", "require-corp"),
283 ("Cross-Origin-Opener-Policy", "same-origin"),
284 ("Cross-Origin-Resource-Policy", "same-origin"),
285];
286
287#[must_use]
294pub fn security_headers(config: &SecurityHeadersConfig) -> BTreeMap<String, String> {
295 let mut headers = BTreeMap::new();
296 if !config.enabled {
297 return headers;
298 }
299 for (name, value) in CLASS_DEFAULT_HEADERS {
300 headers.insert(name.to_owned(), value.to_owned());
301 }
302 for (name, value) in [
303 ("X-Frame-Options", &config.frame_options),
304 ("X-Content-Type-Options", &config.content_type_options),
305 ("X-XSS-Protection", &config.xss_protection),
306 ("Referrer-Policy", &config.referrer_policy),
307 ] {
308 if let Some(value) = value {
309 headers.insert(name.to_owned(), value.clone());
310 }
311 }
312 match &config.permissions_policy {
313 Some(value) if value.is_empty() => {
314 headers.remove("Permissions-Policy");
315 }
316 Some(value) => {
317 headers.insert("Permissions-Policy".to_owned(), value.clone());
318 }
319 None => {}
320 }
321 if !config.csp.is_empty() {
322 headers.insert("Content-Security-Policy".to_owned(), build_csp(&config.csp));
323 }
324 if let Some(hsts) = config.hsts.as_ref()
325 && let Some(header) = build_hsts(hsts)
326 {
327 headers.insert("Strict-Transport-Security".to_owned(), header);
328 }
329 for (name, value) in &config.custom {
330 headers.insert(name.clone(), value.clone());
331 }
332 headers
333}
334
335#[cfg(test)]
336mod tests {
337 use super::*;
338
339 #[test]
340 fn reference_default_matches_the_corpus_default_set() {
341 let headers = security_headers(&SecurityHeadersConfig::reference_default());
342 assert_eq!(headers.len(), 11, "the ten class defaults plus HSTS");
343 assert_eq!(
344 headers.get("Strict-Transport-Security").map(String::as_str),
345 Some("max-age=31536000; includeSubDomains")
346 );
347 assert_eq!(
348 headers.get("X-Frame-Options").map(String::as_str),
349 Some("SAMEORIGIN")
350 );
351 assert!(!headers.contains_key("Content-Security-Policy"));
352 }
353
354 #[test]
355 fn disabled_yields_no_headers() {
356 let config = SecurityHeadersConfig {
357 enabled: false,
358 ..SecurityHeadersConfig::reference_default()
359 };
360 assert!(security_headers(&config).is_empty());
361 }
362
363 #[test]
364 fn overrides_custom_csp_and_hsts_compose_like_the_reference() {
365 let config = SecurityHeadersConfig {
366 enabled: true,
367 hsts: Some(HstsConfig {
368 max_age: Some(63_072_000),
369 include_subdomains: true,
370 preload: false,
371 }),
372 csp: vec![CspDirective::new("default-src", &["'self'"])],
373 frame_options: Some("DENY".to_owned()),
374 content_type_options: None,
375 xss_protection: None,
376 referrer_policy: None,
377 permissions_policy: None,
378 custom: BTreeMap::from([("X-Custom-Header".to_owned(), "custom-value".to_owned())]),
379 };
380 let headers = security_headers(&config);
381 assert_eq!(
382 headers.get("X-Frame-Options").map(String::as_str),
383 Some("DENY")
384 );
385 assert_eq!(
386 headers.get("Content-Security-Policy").map(String::as_str),
387 Some("default-src 'self'")
388 );
389 assert_eq!(
390 headers.get("Strict-Transport-Security").map(String::as_str),
391 Some("max-age=63072000; includeSubDomains")
392 );
393 assert_eq!(
394 headers.get("X-Custom-Header").map(String::as_str),
395 Some("custom-value")
396 );
397 assert_eq!(
398 headers.get("X-Content-Type-Options").map(String::as_str),
399 Some("nosniff"),
400 "a None override keeps the class default"
401 );
402 }
403
404 #[test]
405 fn empty_permissions_policy_removes_the_header() {
406 let config = SecurityHeadersConfig {
407 permissions_policy: Some(String::new()),
408 ..SecurityHeadersConfig::reference_default()
409 };
410 assert!(!security_headers(&config).contains_key("Permissions-Policy"));
411 }
412
413 #[test]
414 fn csp_bare_directives_survive_the_build() {
415 let csp = vec![
416 CspDirective::new("default-src", &["'self'", "https:"]),
417 CspDirective::new("upgrade-insecure-requests", &[]),
418 ];
419 assert_eq!(
420 build_csp(&csp),
421 "default-src 'self' https:; upgrade-insecure-requests"
422 );
423 }
424
425 #[test]
426 fn hsts_preload_corrections_apply() {
427 let corrected = build_hsts(&HstsConfig {
428 max_age: Some(1000),
429 include_subdomains: false,
430 preload: true,
431 })
432 .expect("max_age set");
433 assert_eq!(
434 corrected, "max-age=1000; includeSubDomains",
435 "preload drops below the max-age floor and forces includeSubDomains"
436 );
437 let full = build_hsts(&HstsConfig {
438 max_age: Some(31_536_000),
439 include_subdomains: true,
440 preload: true,
441 })
442 .expect("max_age set");
443 assert_eq!(full, "max-age=31536000; includeSubDomains; preload");
444 let minimal = build_hsts(&HstsConfig {
445 max_age: Some(600),
446 include_subdomains: false,
447 preload: false,
448 })
449 .expect("max_age set");
450 assert_eq!(
451 minimal, "max-age=600",
452 "both optional parts stay off their flags"
453 );
454 assert!(
455 build_hsts(&HstsConfig {
456 max_age: None,
457 include_subdomains: true,
458 preload: false,
459 })
460 .is_none()
461 );
462 let config = SecurityHeadersConfig {
464 hsts: Some(HstsConfig {
465 max_age: None,
466 include_subdomains: true,
467 preload: false,
468 }),
469 ..SecurityHeadersConfig::reference_default()
470 };
471 assert!(!security_headers(&config).contains_key("Strict-Transport-Security"));
472 }
473
474 #[test]
475 fn validation_rejects_the_reference_failures() {
476 let bad_name = SecurityHeadersConfig {
477 custom: BTreeMap::from([("bad name\n".to_owned(), "v".to_owned())]),
478 ..SecurityHeadersConfig::reference_default()
479 };
480 assert_eq!(
481 bad_name.validate(),
482 Err(SecurityHeadersError::InvalidName {
483 name: "bad name\n".to_owned()
484 })
485 );
486 let bad_value = SecurityHeadersConfig {
487 frame_options: Some("DENY\r\nX-Evil: 1".to_owned()),
488 ..SecurityHeadersConfig::reference_default()
489 };
490 assert_eq!(
491 bad_value.validate(),
492 Err(SecurityHeadersError::InvalidValue {
493 name: "X-Frame-Options".to_owned(),
494 reason: "invalid header value contains newline",
495 })
496 );
497 let long = SecurityHeadersConfig {
498 frame_options: Some("x".repeat(MAX_HEADER_VALUE_BYTES + 1)),
499 ..SecurityHeadersConfig::reference_default()
500 };
501 assert!(matches!(
502 long.validate(),
503 Err(SecurityHeadersError::InvalidValue {
504 reason: "header value too long",
505 ..
506 })
507 ));
508 assert!(
509 SecurityHeadersConfig::reference_default()
510 .validate()
511 .is_ok()
512 );
513 }
514
515 #[test]
516 fn header_value_sanitization_drops_control_characters_except_tab() {
517 assert_eq!(
518 validate_header_value("a\u{8}b\tc").expect("sanitizable"),
519 "ab\tc"
520 );
521 assert!(is_valid_header_name("X-Custom-Header"));
522 assert!(!is_valid_header_name("X Custom"));
523 assert!(!is_valid_header_name(""));
524 }
525}
526
527#[cfg(test)]
528mod gap_tests {
529 use super::*;
530
531 #[test]
532 fn a_custom_header_with_an_unsanitizable_value_is_rejected() {
533 let config = SecurityHeadersConfig {
534 custom: std::iter::once(("X-Custom".to_owned(), "bad\nvalue".to_owned())).collect(),
535 ..SecurityHeadersConfig::reference_default()
536 };
537 let error = config.validate().expect_err("newline value");
538 assert_eq!(
539 error,
540 SecurityHeadersError::InvalidValue {
541 name: "X-Custom".to_owned(),
542 reason: validate_header_value("bad\nvalue").unwrap_err(),
543 }
544 );
545 }
546
547 #[test]
548 fn an_invalid_custom_header_name_displays_the_reference_message() {
549 let config = SecurityHeadersConfig {
550 custom: std::iter::once(("X Custom".to_owned(), "value".to_owned())).collect(),
551 ..SecurityHeadersConfig::reference_default()
552 };
553 let error = config.validate().expect_err("space in name");
554 assert_eq!(error.to_string(), "invalid header name: X Custom");
555 }
556}
557
558#[cfg(test)]
559mod unit_twins {
560 use super::*;
561
562 #[test]
563 fn a_valid_optional_header_passes_validation() {
564 let config = SecurityHeadersConfig {
567 frame_options: Some("DENY".to_owned()),
568 ..SecurityHeadersConfig::reference_default()
569 };
570 assert!(config.validate().is_ok());
571 let config = SecurityHeadersConfig {
573 frame_options: None,
574 content_type_options: None,
575 xss_protection: None,
576 referrer_policy: None,
577 permissions_policy: None,
578 custom: std::collections::BTreeMap::new(),
579 ..SecurityHeadersConfig::reference_default()
580 };
581 assert!(config.validate().is_ok());
582 }
583
584 #[test]
585 fn the_error_variants_display_the_reference_messages() {
586 let name_error = SecurityHeadersError::InvalidName {
587 name: "X Custom".to_owned(),
588 };
589 assert_eq!(name_error.to_string(), "invalid header name: X Custom");
590 let value_error = SecurityHeadersError::InvalidValue {
591 name: "X-Custom".to_owned(),
592 reason: "control character",
593 };
594 assert_eq!(
595 value_error.to_string(),
596 "invalid value for header X-Custom: control character"
597 );
598 }
599}