Skip to main content

guard_core_engine/
security_headers.rs

1//! Security headers management, ported from the reference handler.
2//!
3//! The engine owns the logic; adapters (and the conformance runner) apply
4//! the returned map to their responses. [`security_headers`] ports
5//! `get_headers` resolved against a `security_headers` dict: a disabled
6//! configuration yields no headers, the class defaults apply, the
7//! configured overrides replace the matching class header, the CSP and
8//! HSTS blocks extend them, and the custom headers land last.
9//!
10//! Reference sources: `security_headers_handler.py`, its config mixin
11//! (`_security_headers_config.py`), and the field defaults
12//! (`_security_config_fields.py`).
13//!
14//! # Example
15//!
16//! ```
17//! use guard_core_engine::security_headers::{security_headers, SecurityHeadersConfig};
18//!
19//! // The reference default block: the class defaults plus the
20//! // max-age=31536000; includeSubDomains HSTS header.
21//! let headers = security_headers(&SecurityHeadersConfig::reference_default());
22//! assert_eq!(headers.get("X-Content-Type-Options").map(String::as_str), Some("nosniff"));
23//! assert_eq!(
24//!     headers.get("Strict-Transport-Security").map(String::as_str),
25//!     Some("max-age=31536000; includeSubDomains")
26//! );
27//!
28//! // Disabled: no headers at all (the reference `get_headers` early return).
29//! let off = SecurityHeadersConfig {
30//!     enabled: false,
31//!     ..SecurityHeadersConfig::default()
32//! };
33//! assert!(security_headers(&off).is_empty());
34//! ```
35
36use std::collections::BTreeMap;
37
38/// The reference `security_headers["hsts"]` block: `max_age`,
39/// `include_subdomains`, `preload`.
40///
41/// `max_age: None` means no Strict-Transport-Security header, exactly
42/// like the reference `hsts_config = None` when `max_age` is absent.
43#[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        // The reference security_headers dict default
53        // (_security_config_fields.py security_headers).
54        Self {
55            max_age: Some(31_536_000),
56            include_subdomains: true,
57            preload: false,
58        }
59    }
60}
61
62/// One Content-Security-Policy directive with its sources, in the
63/// insertion order the reference dict keeps (a Vec keeps the built header
64/// deterministic).
65#[derive(Debug, Clone, PartialEq, Eq)]
66pub struct CspDirective {
67    pub name: String,
68    pub sources: Vec<String>,
69}
70
71impl CspDirective {
72    /// A directive with a single source list, the corpus shape.
73    #[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/// The reference `security_headers` dict: `enabled`, `hsts`, `csp`,
83/// `frame_options`, `content_type_options`, `xss_protection`,
84/// `referrer_policy`, `permissions_policy`, and `custom`.
85///
86/// `None` in the override fields keeps the class default header (the
87/// reference dict lookup returning no key); a set value overrides it. The
88/// reference's three-way `permissions_policy` ("UNSET" sentinel keeps the
89/// default, a falsy value removes the header) resolves the same way:
90/// `None` keeps the default and an empty string removes the header.
91#[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    /// `None` keeps the class default, `Some("")` removes the header, any
101    /// other value overrides it.
102    pub permissions_policy: Option<String>,
103    pub custom: BTreeMap<String, String>,
104}
105
106impl SecurityHeadersConfig {
107    /// The reference `SecurityConfig.security_headers` default block
108    /// (`_security_config_fields.py`): enabled, HSTS
109    /// max-age=31536000+includeSubDomains, no CSP, SAMEORIGIN / nosniff /
110    /// `1; mode=block` / strict-origin-when-cross-origin, the camera and
111    /// microphone lockdown Permissions-Policy, no custom headers.
112    #[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    /// Validate the configured values the way the reference `configure()`
128    /// raises out of `_validate_header_name` / `_validate_header_value`:
129    /// a custom name must be a non-empty RFC 7230 token, and no configured
130    /// value may carry CR/LF, exceed 8192 bytes, or (after sanitization)
131    /// keep control characters below 0x20 other than tab.
132    ///
133    /// # Errors
134    ///
135    /// [`SecurityHeadersError`] naming the offending header on the first
136    /// invalid entry.
137    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/// A rejected security-headers configuration: the reference raises out of
168/// `configure()`, so a deployment never starts with these values.
169#[derive(Debug, Clone, PartialEq, Eq)]
170pub enum SecurityHeadersError {
171    /// A custom header name that is not an RFC 7230 token.
172    InvalidName {
173        /// The rejected name.
174        name: String,
175    },
176    /// A header value carrying a newline, exceeding 8192 bytes, or
177    /// otherwise unsanitizable.
178    InvalidValue {
179        /// The header the value belongs to.
180        name: String,
181        /// Why the value was rejected.
182        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/// `_HEADER_NAME_TOKEN_RE`: a non-empty RFC 7230 token.
200#[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
210/// `_validate_header_value`: no CR/LF, at most 8192 bytes, control
211/// characters below 0x20 dropped except tab.
212///
213/// # Errors
214///
215/// A static reason string naming the first violated rule.
216pub 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/// `_build_csp`: "directive source1 source2" joined with "; ", bare
227/// directives kept when they carry no sources.
228#[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/// `_build_hsts` plus the `_compute_hsts_config` preload corrections.
243///
244/// Preload requires `max_age >= 31536000` and includeSubDomains (the
245/// reference logs a warning and drops/corrects the flag instead of
246/// rejecting).
247#[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
270/// `SecurityHeadersManager.default_headers`, in table order.
271pub 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/// `SecurityHeadersManager.get_headers` resolved against the reference
288/// `security_headers` dict.
289///
290/// A disabled configuration yields no headers, the class defaults apply,
291/// the configured overrides replace the matching class header, the CSP
292/// and HSTS blocks extend them, and the custom headers land last.
293#[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        // An HSTS block whose max_age is unset contributes no header.
463        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        // a populated fixed header (frame options) walks the Some arm of
565        // the fixed-header loop and validates clean
566        let config = SecurityHeadersConfig {
567            frame_options: Some("DENY".to_owned()),
568            ..SecurityHeadersConfig::reference_default()
569        };
570        assert!(config.validate().is_ok());
571        // unset fixed headers skip their arm and still validate clean
572        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}