proven/
safe_header.rs

1// SPDX-License-Identifier: AGPL-3.0-or-later
2// SPDX-FileCopyrightText: 2025 Hyperpolymath
3
4//! Safe HTTP Header operations that prevent CRLF injection attacks.
5//!
6//! All operations handle injection attacks and size limits without panicking.
7//! Operations return `Result` on failure.
8
9use crate::core::{Error, Result};
10
11/// HTTP header name/value pair
12#[derive(Debug, Clone, PartialEq, Eq)]
13pub struct Header {
14    /// Header name (validated token)
15    pub name: String,
16    /// Header value (CRLF-free)
17    pub value: String,
18}
19
20/// SameSite attribute for cookies
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub enum SameSite {
23    /// Strict same-site policy
24    Strict,
25    /// Lax same-site policy
26    Lax,
27    /// No same-site restriction (requires Secure)
28    None,
29}
30
31/// Safe header operations
32pub struct SafeHeader;
33
34impl SafeHeader {
35    /// Check if a string contains CRLF injection characters
36    pub fn has_crlf(s: &str) -> bool {
37        s.contains('\r') || s.contains('\n')
38    }
39
40    /// Check if header name is a valid token per RFC 7230
41    pub fn is_valid_name(name: &str) -> bool {
42        if name.is_empty() || name.len() > 256 {
43            return false;
44        }
45        name.chars().all(Self::is_valid_token_char)
46    }
47
48    /// Check if character is valid for HTTP token
49    fn is_valid_token_char(c: char) -> bool {
50        // Token chars per RFC 7230: !#$%&'*+-.^_`|~ plus alphanumeric
51        matches!(c,
52            '!' | '#' | '$' | '%' | '&' | '\'' | '*' | '+' | '-' | '.' |
53            '^' | '_' | '`' | '|' | '~' |
54            'a'..='z' | 'A'..='Z' | '0'..='9'
55        )
56    }
57
58    /// List of dangerous headers that should not be set by user code
59    const DANGEROUS_HEADERS: &'static [&'static str] = &[
60        "proxy-authorization",
61        "proxy-authenticate",
62        "proxy-connection",
63        "transfer-encoding",
64        "content-length",
65        "host",
66        "connection",
67        "keep-alive",
68        "upgrade",
69        "te",
70        "trailer",
71    ];
72
73    /// Check if header name is in the dangerous headers list
74    pub fn is_dangerous(name: &str) -> bool {
75        let lower = name.to_lowercase();
76        Self::DANGEROUS_HEADERS.contains(&lower.as_str())
77    }
78
79    /// Create a validated header
80    pub fn make(name: &str, value: &str) -> Result<Header> {
81        let trimmed_name = name.trim();
82        let trimmed_value = value.trim();
83
84        if !Self::is_valid_name(trimmed_name) {
85            return Err(Error::InvalidFormat("Invalid header name".into()));
86        }
87
88        if Self::has_crlf(trimmed_value) {
89            return Err(Error::InvalidFormat("Header value contains CRLF".into()));
90        }
91
92        if trimmed_value.len() > 8192 {
93            return Err(Error::TooLong("Header value too long".into()));
94        }
95
96        Ok(Header {
97            name: trimmed_name.to_string(),
98            value: trimmed_value.to_string(),
99        })
100    }
101
102    /// Create header, blocking dangerous headers
103    pub fn make_safe(name: &str, value: &str) -> Result<Header> {
104        if Self::is_dangerous(name) {
105            return Err(Error::InvalidFormat("Dangerous header not allowed".into()));
106        }
107        Self::make(name, value)
108    }
109
110    /// Render header to "Name: Value" format
111    pub fn render(header: &Header) -> String {
112        format!("{}: {}", header.name, header.value)
113    }
114
115    /// Build Strict-Transport-Security header value
116    pub fn build_hsts(max_age: u64, include_subdomains: bool, preload: bool) -> String {
117        let mut value = format!("max-age={}", max_age);
118        if include_subdomains {
119            value.push_str("; includeSubDomains");
120        }
121        if preload {
122            value.push_str("; preload");
123        }
124        value
125    }
126
127    /// Build Content-Security-Policy header value from directives
128    pub fn build_csp(directives: &[(String, Vec<String>)]) -> String {
129        directives
130            .iter()
131            .map(|(name, sources)| {
132                if sources.is_empty() {
133                    name.clone()
134                } else {
135                    format!("{} {}", name, sources.join(" "))
136                }
137            })
138            .collect::<Vec<_>>()
139            .join("; ")
140    }
141
142    /// Get common security headers preset
143    pub fn security_headers() -> Vec<Header> {
144        vec![
145            Header {
146                name: "X-Frame-Options".to_string(),
147                value: "DENY".to_string(),
148            },
149            Header {
150                name: "X-Content-Type-Options".to_string(),
151                value: "nosniff".to_string(),
152            },
153            Header {
154                name: "Referrer-Policy".to_string(),
155                value: "strict-origin-when-cross-origin".to_string(),
156            },
157            Header {
158                name: "X-XSS-Protection".to_string(),
159                value: "1; mode=block".to_string(),
160            },
161        ]
162    }
163}
164
165#[cfg(test)]
166mod tests {
167    use super::*;
168
169    #[test]
170    fn test_has_crlf() {
171        assert!(!SafeHeader::has_crlf("application/json"));
172        assert!(SafeHeader::has_crlf("text\r\nX-Injected: evil"));
173        assert!(SafeHeader::has_crlf("value\ninjected"));
174    }
175
176    #[test]
177    fn test_is_valid_name() {
178        assert!(SafeHeader::is_valid_name("Content-Type"));
179        assert!(SafeHeader::is_valid_name("X-Custom-Header"));
180        assert!(!SafeHeader::is_valid_name("Content:Type"));
181        assert!(!SafeHeader::is_valid_name(""));
182    }
183
184    #[test]
185    fn test_is_dangerous() {
186        assert!(SafeHeader::is_dangerous("Host"));
187        assert!(SafeHeader::is_dangerous("Transfer-Encoding"));
188        assert!(!SafeHeader::is_dangerous("X-Custom-Header"));
189    }
190
191    #[test]
192    fn test_build_hsts() {
193        let hsts = SafeHeader::build_hsts(31536000, true, true);
194        assert_eq!(hsts, "max-age=31536000; includeSubDomains; preload");
195    }
196
197    #[test]
198    fn test_make_header() {
199        let header = SafeHeader::make("Content-Type", "application/json").unwrap();
200        assert_eq!(header.name, "Content-Type");
201        assert_eq!(header.value, "application/json");
202    }
203
204    #[test]
205    fn test_reject_crlf() {
206        let result = SafeHeader::make("Content-Type", "text\r\nX-Injected: evil");
207        assert!(result.is_err());
208    }
209}