blueprint_auth/
validation.rs

1use std::collections::{BTreeMap, HashSet};
2
3/// Maximum number of additional headers allowed
4const MAX_HEADERS: usize = 8;
5
6/// Maximum length for header names and values
7const MAX_HEADER_NAME_LEN: usize = 256;
8const MAX_HEADER_VALUE_LEN: usize = 512;
9
10/// Headers that should not be forwarded (hop-by-hop headers)
11const FORBIDDEN_HEADERS: &[&str] = &[
12    "connection",
13    "keep-alive",
14    "proxy-authenticate",
15    "proxy-authorization",
16    "te",
17    "trailer",
18    "transfer-encoding",
19    "upgrade",
20    "host",
21    "content-length",
22];
23
24/// Validates and sanitizes additional headers
25/// Headers are normalized to lowercase for case-insensitive handling
26pub fn validate_headers(
27    headers: &BTreeMap<String, String>,
28) -> Result<BTreeMap<String, String>, ValidationError> {
29    let forbidden_set: HashSet<String> =
30        FORBIDDEN_HEADERS.iter().map(|h| h.to_lowercase()).collect();
31
32    let mut validated = BTreeMap::new();
33
34    for (name, value) in headers {
35        // Normalize header name to lowercase for case-insensitive handling
36        let name_lower = name.to_lowercase();
37
38        if forbidden_set.contains(&name_lower) {
39            return Err(ValidationError::ForbiddenHeader {
40                header: name.clone(),
41            });
42        }
43
44        if name.len() > MAX_HEADER_NAME_LEN {
45            return Err(ValidationError::HeaderNameTooLong {
46                header: name.clone(),
47                max: MAX_HEADER_NAME_LEN,
48            });
49        }
50
51        if value.len() > MAX_HEADER_VALUE_LEN {
52            return Err(ValidationError::HeaderValueTooLong {
53                header: name.clone(),
54                max: MAX_HEADER_VALUE_LEN,
55            });
56        }
57
58        // Validate that header name only contains valid characters
59        if !is_valid_header_name(name) {
60            return Err(ValidationError::InvalidHeaderName {
61                header: name.clone(),
62            });
63        }
64
65        // Validate that header value only contains valid characters
66        if !is_valid_header_value(value) {
67            return Err(ValidationError::InvalidHeaderValue {
68                header: name.clone(),
69                value: value.clone(),
70            });
71        }
72
73        // Store with normalized lowercase name (HTTP headers are case-insensitive)
74        // This ensures that later headers with different cases override earlier ones
75        validated.insert(name_lower, value.clone());
76    }
77
78    // Check the final count after case-insensitive merging
79    if validated.len() > MAX_HEADERS {
80        return Err(ValidationError::TooManyHeaders {
81            max: MAX_HEADERS,
82            provided: validated.len(),
83        });
84    }
85
86    Ok(validated)
87}
88
89/// Check if a header name contains only valid characters
90fn is_valid_header_name(name: &str) -> bool {
91    !name.is_empty()
92        && name
93            .chars()
94            .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
95}
96
97/// Check if a header value contains only valid characters
98fn is_valid_header_value(value: &str) -> bool {
99    value.chars().all(|c| {
100        // Allow printable ASCII characters and spaces
101        (c.is_ascii() && !c.is_control()) || c == '\t'
102    })
103}
104
105/// Hash a user ID to create a tenant ID
106pub fn hash_user_id(user_id: &str) -> String {
107    use tiny_keccak::{Hasher, Keccak};
108    let mut hasher = Keccak::v256();
109    hasher.update(user_id.as_bytes());
110    let mut output = [0u8; 32];
111    hasher.finalize(&mut output);
112    // Use first 16 bytes of hash for a compact representation
113    hex::encode(&output[..16])
114}
115
116/// Process headers with PII protection
117/// Hashes user IDs and emails in known PII headers
118/// Note: headers should already be normalized to lowercase
119pub fn process_headers_with_pii_protection(
120    headers: &BTreeMap<String, String>,
121) -> BTreeMap<String, String> {
122    let mut processed = BTreeMap::new();
123
124    for (name, value) in headers {
125        // Headers should already be lowercase, but ensure consistency
126        let processed_value = match name.as_str() {
127            // Hash PII fields
128            "x-user-id" | "x-user-email" | "x-customer-email" => hash_user_id(value),
129            // For tenant ID, check if it looks like an email or raw ID
130            "x-tenant-id" => {
131                if value.contains('@') {
132                    // It's an email, hash it
133                    hash_user_id(value)
134                } else if value.len() == 32 && value.chars().all(|c| c.is_ascii_hexdigit()) {
135                    // Already looks like a hash, keep it
136                    value.clone()
137                } else {
138                    // Raw ID, hash it for privacy
139                    hash_user_id(value)
140                }
141            }
142            // Keep other headers as-is
143            _ => value.clone(),
144        };
145        processed.insert(name.clone(), processed_value);
146    }
147
148    processed
149}
150
151#[derive(Debug, thiserror::Error)]
152pub enum ValidationError {
153    #[error("Too many headers provided: {provided} (max: {max})")]
154    TooManyHeaders { max: usize, provided: usize },
155
156    #[error("Forbidden header: {header}")]
157    ForbiddenHeader { header: String },
158
159    #[error("Header name too long: {header} (max: {max} bytes)")]
160    HeaderNameTooLong { header: String, max: usize },
161
162    #[error("Header value too long for {header} (max: {max} bytes)")]
163    HeaderValueTooLong { header: String, max: usize },
164
165    #[error("Invalid header name: {header}")]
166    InvalidHeaderName { header: String },
167
168    #[error("Invalid header value for {header}: {value}")]
169    InvalidHeaderValue { header: String, value: String },
170}
171
172#[cfg(test)]
173mod tests {
174    use super::*;
175
176    #[test]
177    fn test_validate_headers_valid() {
178        let mut headers = BTreeMap::new();
179        headers.insert("X-Tenant-Id".to_string(), "abc123".to_string());
180        headers.insert("X-User-Type".to_string(), "premium".to_string());
181
182        let result = validate_headers(&headers);
183        assert!(result.is_ok());
184        let validated = result.unwrap();
185        assert_eq!(validated.len(), 2);
186    }
187
188    #[test]
189    fn test_validate_headers_too_many() {
190        let mut headers = BTreeMap::new();
191        for i in 0..10 {
192            headers.insert(format!("X-Header-{i}"), "value".to_string());
193        }
194
195        let result = validate_headers(&headers);
196        assert!(matches!(
197            result,
198            Err(ValidationError::TooManyHeaders { .. })
199        ));
200    }
201
202    #[test]
203    fn test_validate_headers_forbidden() {
204        let mut headers = BTreeMap::new();
205        headers.insert("Connection".to_string(), "close".to_string());
206
207        let result = validate_headers(&headers);
208        assert!(matches!(
209            result,
210            Err(ValidationError::ForbiddenHeader { .. })
211        ));
212    }
213
214    #[test]
215    fn test_validate_headers_invalid_name() {
216        let mut headers = BTreeMap::new();
217        headers.insert("X-Invalid Header".to_string(), "value".to_string());
218
219        let result = validate_headers(&headers);
220        assert!(matches!(
221            result,
222            Err(ValidationError::InvalidHeaderName { .. })
223        ));
224    }
225
226    #[test]
227    fn test_validate_headers_name_too_long() {
228        let mut headers = BTreeMap::new();
229        let long_name = "X-".to_string() + &"a".repeat(300);
230        headers.insert(long_name, "value".to_string());
231
232        let result = validate_headers(&headers);
233        assert!(matches!(
234            result,
235            Err(ValidationError::HeaderNameTooLong { .. })
236        ));
237    }
238
239    #[test]
240    fn test_validate_headers_value_too_long() {
241        let mut headers = BTreeMap::new();
242        let long_value = "a".repeat(600);
243        headers.insert("X-Test".to_string(), long_value);
244
245        let result = validate_headers(&headers);
246        assert!(matches!(
247            result,
248            Err(ValidationError::HeaderValueTooLong { .. })
249        ));
250    }
251
252    #[test]
253    fn test_hash_user_id() {
254        let user_id = "user123@example.com";
255        let hash1 = hash_user_id(user_id);
256        let hash2 = hash_user_id(user_id);
257
258        // Should be deterministic
259        assert_eq!(hash1, hash2);
260
261        // Should be 32 characters (16 bytes hex encoded)
262        assert_eq!(hash1.len(), 32);
263
264        // Different inputs should produce different hashes
265        let hash3 = hash_user_id("different@example.com");
266        assert_ne!(hash1, hash3);
267    }
268
269    #[test]
270    fn test_valid_header_name() {
271        assert!(is_valid_header_name("X-Tenant-Id"));
272        assert!(is_valid_header_name("X_User_Type"));
273        assert!(is_valid_header_name("Authorization"));
274
275        assert!(!is_valid_header_name(""));
276        assert!(!is_valid_header_name("X Tenant Id"));
277        assert!(!is_valid_header_name("X-Tenant:Id"));
278    }
279
280    #[test]
281    fn test_valid_header_value() {
282        assert!(is_valid_header_value("abc123"));
283        assert!(is_valid_header_value("Bearer token123"));
284        assert!(is_valid_header_value("value with spaces"));
285
286        assert!(!is_valid_header_value("value\nwith\nnewlines"));
287        assert!(!is_valid_header_value("value\0with\0nulls"));
288    }
289
290    #[test]
291    fn test_header_case_insensitive_override() {
292        let mut headers = BTreeMap::new();
293        // Add header with uppercase
294        headers.insert("X-Tenant-Id".to_string(), "first_value".to_string());
295        // Add same header with different case
296        headers.insert("x-tenant-id".to_string(), "second_value".to_string());
297        // Add another variation
298        headers.insert("X-TENANT-ID".to_string(), "third_value".to_string());
299
300        let result = validate_headers(&headers).unwrap();
301
302        // Should only have one header (normalized to lowercase)
303        assert_eq!(result.len(), 1);
304        assert!(result.contains_key("x-tenant-id"));
305
306        // The value should be from the last one processed
307        // Note: BTreeMap iterates in lexicographic order, so "X-TENANT-ID" < "X-Tenant-Id" < "x-tenant-id"
308        // The last one in iteration order wins
309        let value = result.get("x-tenant-id").unwrap();
310        assert!(
311            value == "first_value" || value == "second_value" || value == "third_value",
312            "Value should be one of the provided values, got: {value}"
313        );
314    }
315
316    #[test]
317    fn test_multiple_headers_case_insensitive() {
318        let mut headers = BTreeMap::new();
319        headers.insert("X-User-Id".to_string(), "user123".to_string());
320        headers.insert("x-tenant-id".to_string(), "tenant456".to_string());
321        headers.insert("X-TENANT-ID".to_string(), "tenant789".to_string()); // Override
322        headers.insert("Content-Type".to_string(), "application/json".to_string());
323
324        let result = validate_headers(&headers).unwrap();
325
326        // Should have 3 unique headers after case-insensitive merging
327        assert_eq!(result.len(), 3);
328        assert!(result.contains_key("x-user-id"));
329        assert!(result.contains_key("x-tenant-id"));
330        assert!(result.contains_key("content-type"));
331
332        // x-tenant-id should have been overridden
333        let tenant_value = result.get("x-tenant-id").unwrap();
334        assert!(
335            tenant_value == "tenant456" || tenant_value == "tenant789",
336            "Tenant ID should be one of the override values"
337        );
338    }
339}