Skip to main content

rustlavel_http/
headers.rs

1//! Case-insensitive header storage.
2//!
3//! Names are lowercased on insert, which is how HTTP/2 sends them anyway, and
4//! makes lookups a plain map hit rather than a scan.
5
6use std::collections::BTreeMap;
7
8#[derive(Debug, Clone, Default, PartialEq, Eq)]
9pub struct Headers {
10    entries: BTreeMap<String, Vec<String>>,
11}
12
13/// Take the line terminators out of a header name or value.
14///
15/// A carriage return or newline in a header value ends the header — and, if
16/// there are two, the whole head. So a value an attacker reaches turns into
17/// headers of their choosing and then a body of their choosing: a `Set-Cookie`
18/// that fixes a session, or a second response the cache in front will serve to
19/// somebody else. Response splitting, and the shape it usually arrives in is
20/// ordinary: `Response::see_other(req.input("next")...)`, where `next` came
21/// from a form and `%0d%0a` survived being decoded.
22///
23/// Removed rather than refused, because `set` has no way to report a refusal
24/// and an API change here would reach every call site in the framework. A
25/// header value with a newline in it is a bug or an attack in every case, so
26/// there is nothing worth preserving in the part that is dropped.
27///
28/// The other C0 controls go too: they have no meaning in a header, and a NUL
29/// is read differently by different proxies, which is the same class of
30/// disagreement that makes request smuggling work.
31fn sanitise(text: &str) -> String {
32    text.chars().filter(|c| *c == '\t' || !c.is_control()).collect()
33}
34
35impl Headers {
36    pub fn new() -> Self {
37        Self::default()
38    }
39
40    /// Replace any existing values for this name.
41    pub fn set(&mut self, name: &str, value: impl Into<String>) {
42        self.entries.insert(sanitise(name).to_ascii_lowercase(), vec![sanitise(&value.into())]);
43    }
44
45    /// Add a value, keeping the ones already present. Used for `Set-Cookie`,
46    /// which is the one header that legitimately repeats.
47    pub fn append(&mut self, name: &str, value: impl Into<String>) {
48        self.entries
49            .entry(sanitise(name).to_ascii_lowercase())
50            .or_default()
51            .push(sanitise(&value.into()));
52    }
53
54    pub fn get(&self, name: &str) -> Option<&str> {
55        self.entries.get(&name.to_ascii_lowercase()).and_then(|v| v.first()).map(String::as_str)
56    }
57
58    pub fn get_all(&self, name: &str) -> &[String] {
59        self.entries.get(&name.to_ascii_lowercase()).map_or(&[], Vec::as_slice)
60    }
61
62    pub fn contains(&self, name: &str) -> bool {
63        self.entries.contains_key(&name.to_ascii_lowercase())
64    }
65
66    pub fn remove(&mut self, name: &str) {
67        self.entries.remove(&name.to_ascii_lowercase());
68    }
69
70    pub fn is_empty(&self) -> bool {
71        self.entries.is_empty()
72    }
73
74    /// Iterate every (name, value) pair, repeated headers included.
75    pub fn iter(&self) -> impl Iterator<Item = (&str, &str)> {
76        self.entries
77            .iter()
78            .flat_map(|(name, values)| values.iter().map(move |value| (name.as_str(), value.as_str())))
79    }
80
81    /// The parsed `Content-Length`, when present and well-formed.
82    pub fn content_length(&self) -> Option<usize> {
83        self.get("content-length")?.trim().parse().ok()
84    }
85
86    /// The media type without parameters: `application/json; charset=utf-8` → `application/json`.
87    pub fn content_type(&self) -> Option<&str> {
88        let value = self.get("content-type")?;
89        Some(value.split(';').next().unwrap_or(value).trim())
90    }
91}
92
93#[cfg(test)]
94mod tests {
95
96    /// Response splitting: a newline in a header value ends the header, and two
97    /// end the whole head — so a value an attacker reaches becomes headers of
98    /// their choosing and then a body of their choosing. The shape it arrives
99    /// in is ordinary: a redirect built from a form field where `%0d%0a`
100    /// survived being decoded.
101    #[test]
102    fn a_newline_cannot_be_smuggled_into_a_header() {
103        let mut headers = Headers::new();
104        headers.set("location", "/next\r\nset-cookie: session=attacker\r\n\r\n<html>");
105
106        let value = headers.get("location").expect("the header is still set");
107        assert!(!value.contains('\r') && !value.contains('\n'), "{value:?}");
108        assert!(!value.contains("\r\nset-cookie"), "a second header was smuggled: {value:?}");
109        assert_eq!(value, "/nextset-cookie: session=attacker<html>");
110    }
111
112    #[test]
113    fn append_is_guarded_too_and_so_is_the_name() {
114        let mut headers = Headers::new();
115        headers.append("set-cookie", "a=1\nx-evil: yes");
116        headers.set("x-name\r\ninjected", "fine");
117
118        assert!(!headers.get("set-cookie").unwrap().contains('\n'));
119        assert!(headers.get("x-nameinjected").is_some(), "the name was not cleaned");
120    }
121
122    /// A tab is legal inside a header value, and folding used to rely on it.
123    /// Stripping it would corrupt values nobody was attacking.
124    #[test]
125    fn a_tab_survives() {
126        let mut headers = Headers::new();
127        headers.set("x-thing", "a\tb");
128        assert_eq!(headers.get("x-thing"), Some("a\tb"));
129    }
130    use super::*;
131
132    #[test]
133    fn lookups_ignore_case() {
134        let mut headers = Headers::new();
135        headers.set("Content-Type", "application/json");
136
137        assert_eq!(headers.get("content-type"), Some("application/json"));
138        assert_eq!(headers.get("CONTENT-TYPE"), Some("application/json"));
139        assert!(headers.contains("Content-Type"));
140    }
141
142    #[test]
143    fn set_replaces_while_append_accumulates() {
144        let mut headers = Headers::new();
145        headers.set("x-tag", "one");
146        headers.set("x-tag", "two");
147        assert_eq!(headers.get_all("x-tag"), ["two"]);
148
149        headers.append("set-cookie", "a=1");
150        headers.append("set-cookie", "b=2");
151        assert_eq!(headers.get_all("set-cookie").len(), 2);
152    }
153
154    #[test]
155    fn parses_content_metadata() {
156        let mut headers = Headers::new();
157        headers.set("content-type", "application/json; charset=utf-8");
158        headers.set("content-length", "42");
159
160        assert_eq!(headers.content_type(), Some("application/json"));
161        assert_eq!(headers.content_length(), Some(42));
162    }
163}