Skip to main content

synapse_pingora/
headers.rs

1//! Header manipulation logic for request and response headers.
2//!
3//! Provides functionality to add, set, and remove headers based on configuration.
4//!
5//! # Security
6//!
7//! Sensitive header values (Authorization, Cookie, API keys, etc.) are automatically
8//! redacted in debug logs to prevent credential leakage through log aggregation systems.
9
10use crate::config::{HeaderConfig, HeaderOps};
11use crate::shadow::is_sensitive_header;
12use bytes::Bytes;
13use http::header::{HeaderName, HeaderValue};
14use pingora_http::{RequestHeader, ResponseHeader};
15use tracing::debug;
16
17/// Redact a header value for safe logging.
18///
19/// SECURITY: Sensitive header values are fully redacted to prevent credential leakage.
20/// Non-sensitive headers show the full value for debugging purposes.
21#[inline]
22fn redact_for_log(name: &str, value: &str) -> String {
23    if is_sensitive_header(name) {
24        "[REDACTED]".to_string()
25    } else {
26        value.to_string()
27    }
28}
29
30#[derive(Debug, Clone, Default)]
31pub struct CompiledHeaderOps {
32    pub(crate) add: Vec<CompiledHeaderValue>,
33    pub(crate) set: Vec<CompiledHeaderValue>,
34    pub(crate) remove: Vec<HeaderName>,
35}
36
37#[derive(Debug, Clone, Default)]
38pub struct CompiledHeaderConfig {
39    pub(crate) request: CompiledHeaderOps,
40    pub(crate) response: CompiledHeaderOps,
41}
42
43impl CompiledHeaderConfig {
44    /// Get the request header operations
45    pub fn request(&self) -> &CompiledHeaderOps {
46        &self.request
47    }
48
49    /// Get the response header operations
50    pub fn response(&self) -> &CompiledHeaderOps {
51        &self.response
52    }
53}
54
55#[derive(Debug, Clone)]
56struct CompiledHeaderValue {
57    name: Bytes,
58    value: HeaderValue,
59}
60
61impl CompiledHeaderOps {
62    fn with_capacity(add: usize, set: usize, remove: usize) -> Self {
63        Self {
64            add: Vec::with_capacity(add),
65            set: Vec::with_capacity(set),
66            remove: Vec::with_capacity(remove),
67        }
68    }
69}
70
71impl HeaderConfig {
72    pub fn compile(&self) -> CompiledHeaderConfig {
73        CompiledHeaderConfig {
74            request: self.request.compile(),
75            response: self.response.compile(),
76        }
77    }
78}
79
80impl HeaderOps {
81    pub fn compile(&self) -> CompiledHeaderOps {
82        let mut compiled =
83            CompiledHeaderOps::with_capacity(self.add.len(), self.set.len(), self.remove.len());
84
85        for name in &self.remove {
86            match HeaderName::from_bytes(name.as_bytes()) {
87                Ok(header_name) => compiled.remove.push(header_name),
88                Err(err) => debug!("Invalid remove header name '{}': {}", name, err),
89            }
90        }
91
92        compiled.set = compile_header_entries(&self.set, "set");
93        compiled.add = compile_header_entries(&self.add, "add");
94
95        compiled
96    }
97}
98
99fn compile_header_entries(
100    entries: &std::collections::HashMap<String, String>,
101    op: &'static str,
102) -> Vec<CompiledHeaderValue> {
103    let mut compiled = Vec::with_capacity(entries.len());
104
105    for (name, value) in entries {
106        if let Err(err) = HeaderName::from_bytes(name.as_bytes()) {
107            debug!("Invalid {} header name '{}': {}", op, name, err);
108            continue;
109        }
110
111        match HeaderValue::from_str(value) {
112            Ok(header_value) => compiled.push(CompiledHeaderValue {
113                name: Bytes::copy_from_slice(name.as_bytes()),
114                value: header_value,
115            }),
116            Err(err) => debug!("Invalid {} header value for '{}': {}", op, name, err),
117        }
118    }
119
120    compiled
121}
122
123#[inline]
124fn header_name_for_log(name: &Bytes) -> &str {
125    std::str::from_utf8(name.as_ref()).unwrap_or("<invalid>")
126}
127
128#[inline]
129fn header_value_for_log(value: &HeaderValue) -> &str {
130    value.to_str().unwrap_or("<binary>")
131}
132
133/// Apply header operations to a request header.
134pub fn apply_request_headers(header: &mut RequestHeader, ops: &CompiledHeaderOps) {
135    // 1. Remove headers
136    for name in &ops.remove {
137        if header.remove_header(name).is_some() {
138            debug!("Removed request header: {}", name.as_str());
139        }
140    }
141
142    // 2. Set headers (replace existing)
143    for entry in &ops.set {
144        let name = header_name_for_log(&entry.name);
145        if let Err(e) = header.insert_header(entry.name.clone(), entry.value.clone()) {
146            debug!("Failed to set request header {}: {}", name, e);
147        } else {
148            // SECURITY: Redact sensitive header values in logs
149            let value = header_value_for_log(&entry.value);
150            debug!(
151                "Set request header: {} = {}",
152                name,
153                redact_for_log(name, value)
154            );
155        }
156    }
157
158    // 3. Add headers (append to existing)
159    for entry in &ops.add {
160        let name = header_name_for_log(&entry.name);
161        if let Err(e) = header.append_header(entry.name.clone(), entry.value.clone()) {
162            debug!("Failed to add request header {}: {}", name, e);
163        } else {
164            // SECURITY: Redact sensitive header values in logs
165            let value = header_value_for_log(&entry.value);
166            debug!(
167                "Added request header: {} = {}",
168                name,
169                redact_for_log(name, value)
170            );
171        }
172    }
173}
174
175/// Apply header operations to a response header.
176pub fn apply_response_headers(header: &mut ResponseHeader, ops: &CompiledHeaderOps) {
177    // 1. Remove headers
178    for name in &ops.remove {
179        if header.remove_header(name).is_some() {
180            debug!("Removed response header: {}", name.as_str());
181        }
182    }
183
184    // 2. Set headers (replace existing)
185    for entry in &ops.set {
186        let name = header_name_for_log(&entry.name);
187        if let Err(e) = header.insert_header(entry.name.clone(), entry.value.clone()) {
188            debug!("Failed to set response header {}: {}", name, e);
189        } else {
190            // SECURITY: Redact sensitive header values in logs
191            let value = header_value_for_log(&entry.value);
192            debug!(
193                "Set response header: {} = {}",
194                name,
195                redact_for_log(name, value)
196            );
197        }
198    }
199
200    // 3. Add headers (append to existing)
201    for entry in &ops.add {
202        let name = header_name_for_log(&entry.name);
203        if let Err(e) = header.append_header(entry.name.clone(), entry.value.clone()) {
204            debug!("Failed to add response header {}: {}", name, e);
205        } else {
206            // SECURITY: Redact sensitive header values in logs
207            let value = header_value_for_log(&entry.value);
208            debug!(
209                "Added response header: {} = {}",
210                name,
211                redact_for_log(name, value)
212            );
213        }
214    }
215}
216
217#[inline]
218fn ensure_response_header(header: &mut ResponseHeader, name: &'static str, value: &'static str) {
219    if header.headers.get(name).is_some() {
220        return;
221    }
222
223    if let Err(err) = header.insert_header(name, value) {
224        debug!("Failed to set security header {}: {}", name, err);
225    }
226}
227
228/// Inject baseline security headers onto a response.
229///
230/// Notes:
231/// - Uses "set-if-missing" to avoid overriding application-owned policies.
232/// - HSTS is only injected when the downstream request is HTTPS.
233pub fn apply_security_response_headers(header: &mut ResponseHeader, is_https: bool) {
234    // HSTS is only meaningful over HTTPS; avoid emitting it for cleartext HTTP.
235    if is_https {
236        ensure_response_header(
237            header,
238            "strict-transport-security",
239            "max-age=31536000; includeSubDomains",
240        );
241    }
242
243    ensure_response_header(header, "x-content-type-options", "nosniff");
244    ensure_response_header(header, "x-frame-options", "DENY");
245    ensure_response_header(header, "referrer-policy", "strict-origin-when-cross-origin");
246    ensure_response_header(
247        header,
248        "permissions-policy",
249        "accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=()",
250    );
251}
252
253#[cfg(test)]
254mod tests {
255    use super::*;
256    use pingora_http::RequestHeader;
257
258    #[test]
259    fn test_redact_sensitive_headers() {
260        // Sensitive headers should be fully redacted
261        assert_eq!(
262            redact_for_log("Authorization", "Bearer secret-token"),
263            "[REDACTED]"
264        );
265        assert_eq!(
266            redact_for_log("authorization", "Basic dXNlcjpwYXNz"),
267            "[REDACTED]"
268        );
269        assert_eq!(redact_for_log("Cookie", "session=abc123"), "[REDACTED]");
270        assert_eq!(redact_for_log("X-Api-Key", "sk-live-12345"), "[REDACTED]");
271        assert_eq!(
272            redact_for_log("X-Auth-Token", "auth-token-value"),
273            "[REDACTED]"
274        );
275        assert_eq!(redact_for_log("X-CSRF-Token", "csrf123"), "[REDACTED]");
276    }
277
278    #[test]
279    fn test_redact_non_sensitive_headers() {
280        // Non-sensitive headers should show full value
281        assert_eq!(
282            redact_for_log("Content-Type", "application/json"),
283            "application/json"
284        );
285        assert_eq!(redact_for_log("Accept", "text/html"), "text/html");
286        assert_eq!(redact_for_log("User-Agent", "Mozilla/5.0"), "Mozilla/5.0");
287        assert_eq!(redact_for_log("X-Request-Id", "req-123"), "req-123");
288        assert_eq!(redact_for_log("Cache-Control", "no-cache"), "no-cache");
289    }
290
291    #[test]
292    fn test_redact_case_insensitive() {
293        // Header name matching should be case-insensitive
294        assert_eq!(redact_for_log("AUTHORIZATION", "token"), "[REDACTED]");
295        assert_eq!(redact_for_log("Authorization", "token"), "[REDACTED]");
296        assert_eq!(redact_for_log("authorization", "token"), "[REDACTED]");
297        assert_eq!(redact_for_log("COOKIE", "value"), "[REDACTED]");
298        assert_eq!(redact_for_log("Cookie", "value"), "[REDACTED]");
299        assert_eq!(redact_for_log("cookie", "value"), "[REDACTED]");
300    }
301
302    #[test]
303    fn test_compile_header_ops_skips_invalid_entries() {
304        let mut ops = HeaderOps::default();
305        ops.add
306            .insert("Bad Header".to_string(), "value".to_string());
307        ops.set.insert("X-Good".to_string(), "ok".to_string());
308        ops.remove.push("Another Bad Header".to_string());
309
310        let compiled = ops.compile();
311
312        assert_eq!(compiled.add.len(), 0);
313        assert_eq!(compiled.set.len(), 1);
314        assert_eq!(compiled.remove.len(), 0);
315    }
316
317    #[test]
318    fn test_apply_compiled_request_headers() {
319        let mut ops = HeaderOps::default();
320        ops.add.insert("X-Added".to_string(), "value".to_string());
321        ops.set.insert("X-Set".to_string(), "set-value".to_string());
322        ops.remove.push("X-Remove".to_string());
323
324        let compiled = ops.compile();
325        let mut header = RequestHeader::build("GET", b"/", None).unwrap();
326        header.insert_header("X-Remove", "bye").unwrap();
327
328        apply_request_headers(&mut header, &compiled);
329
330        assert!(header.headers.get("x-remove").is_none());
331        assert_eq!(
332            header.headers.get("x-added").unwrap().to_str().unwrap(),
333            "value"
334        );
335        assert_eq!(
336            header.headers.get("x-set").unwrap().to_str().unwrap(),
337            "set-value"
338        );
339    }
340
341    #[test]
342    fn test_set_header_overwrites_existing_response_header() {
343        let mut ops = HeaderOps::default();
344        ops.set
345            .insert("x-custom".to_string(), "new-value".to_string());
346
347        let compiled = ops.compile();
348        let mut resp = ResponseHeader::build(200, None).unwrap();
349        resp.insert_header("x-custom", "old-value").unwrap();
350
351        apply_response_headers(&mut resp, &compiled);
352
353        // set should overwrite the existing value
354        let values: Vec<&str> = resp
355            .headers
356            .get_all("x-custom")
357            .iter()
358            .map(|v| v.to_str().unwrap())
359            .collect();
360        assert_eq!(values, vec!["new-value"]);
361    }
362
363    #[test]
364    fn test_add_header_appends_to_existing_response_header() {
365        let mut ops = HeaderOps::default();
366        ops.add.insert("x-custom".to_string(), "second".to_string());
367
368        let compiled = ops.compile();
369        let mut resp = ResponseHeader::build(200, None).unwrap();
370        resp.insert_header("x-custom", "first").unwrap();
371
372        apply_response_headers(&mut resp, &compiled);
373
374        // add should append, so both values should be present
375        let values: Vec<&str> = resp
376            .headers
377            .get_all("x-custom")
378            .iter()
379            .map(|v| v.to_str().unwrap())
380            .collect();
381        assert_eq!(values.len(), 2);
382        assert!(values.contains(&"first"));
383        assert!(values.contains(&"second"));
384    }
385
386    #[test]
387    fn test_remove_header_removes_response_header() {
388        let mut ops = HeaderOps::default();
389        ops.remove.push("x-unwanted".to_string());
390
391        let compiled = ops.compile();
392        let mut resp = ResponseHeader::build(200, None).unwrap();
393        resp.insert_header("x-unwanted", "bye").unwrap();
394        resp.insert_header("x-keep", "stay").unwrap();
395
396        apply_response_headers(&mut resp, &compiled);
397
398        assert!(resp.headers.get("x-unwanted").is_none());
399        assert_eq!(
400            resp.headers.get("x-keep").unwrap().to_str().unwrap(),
401            "stay"
402        );
403    }
404
405    #[test]
406    fn test_response_header_ops_order_remove_then_set_then_add() {
407        // The function processes: remove -> set -> add
408        // Verify that removing and then setting the same header works correctly
409        let mut ops = HeaderOps::default();
410        ops.remove.push("x-replaced".to_string());
411        ops.set
412            .insert("x-replaced".to_string(), "set-after-remove".to_string());
413
414        let compiled = ops.compile();
415        let mut resp = ResponseHeader::build(200, None).unwrap();
416        resp.insert_header("x-replaced", "original").unwrap();
417
418        apply_response_headers(&mut resp, &compiled);
419
420        // Should have been removed, then set to the new value
421        assert_eq!(
422            resp.headers.get("x-replaced").unwrap().to_str().unwrap(),
423            "set-after-remove"
424        );
425    }
426
427    #[test]
428    fn test_set_header_creates_new_response_header() {
429        let mut ops = HeaderOps::default();
430        ops.set
431            .insert("x-new-header".to_string(), "fresh".to_string());
432
433        let compiled = ops.compile();
434        let mut resp = ResponseHeader::build(200, None).unwrap();
435
436        // Header does not exist yet
437        assert!(resp.headers.get("x-new-header").is_none());
438
439        apply_response_headers(&mut resp, &compiled);
440
441        assert_eq!(
442            resp.headers.get("x-new-header").unwrap().to_str().unwrap(),
443            "fresh"
444        );
445    }
446
447    #[test]
448    fn test_remove_nonexistent_response_header_is_noop() {
449        let mut ops = HeaderOps::default();
450        ops.remove.push("x-does-not-exist".to_string());
451
452        let compiled = ops.compile();
453        let mut resp = ResponseHeader::build(200, None).unwrap();
454        resp.insert_header("x-keep", "kept").unwrap();
455
456        apply_response_headers(&mut resp, &compiled);
457
458        // No panic, and existing headers are untouched
459        assert_eq!(
460            resp.headers.get("x-keep").unwrap().to_str().unwrap(),
461            "kept"
462        );
463    }
464
465    #[test]
466    fn test_apply_security_response_headers_sets_missing_only() {
467        let mut resp = ResponseHeader::build(200, None).unwrap();
468
469        apply_security_response_headers(&mut resp, false);
470        assert!(resp.headers.get("strict-transport-security").is_none());
471        assert_eq!(
472            resp.headers
473                .get("x-content-type-options")
474                .unwrap()
475                .to_str()
476                .unwrap(),
477            "nosniff"
478        );
479        assert_eq!(
480            resp.headers
481                .get("x-frame-options")
482                .unwrap()
483                .to_str()
484                .unwrap(),
485            "DENY"
486        );
487        assert_eq!(
488            resp.headers
489                .get("referrer-policy")
490                .unwrap()
491                .to_str()
492                .unwrap(),
493            "strict-origin-when-cross-origin"
494        );
495        assert!(resp.headers.get("permissions-policy").is_some());
496
497        // Should not overwrite existing application value
498        resp.insert_header("x-frame-options", "SAMEORIGIN").unwrap();
499        apply_security_response_headers(&mut resp, true);
500        assert_eq!(
501            resp.headers
502                .get("x-frame-options")
503                .unwrap()
504                .to_str()
505                .unwrap(),
506            "SAMEORIGIN"
507        );
508        assert!(resp.headers.get("strict-transport-security").is_some());
509    }
510}