Skip to main content

bezant/
jar.rs

1//! Name-keyed cookie store for the single-host CPGateway use case.
2//!
3//! `reqwest::cookie::Jar` keys cookies by `(domain, path, name)`, which
4//! means two `Set-Cookie` responses from the same Gateway that pick
5//! different paths (e.g. one at `/v1/api/` from a keepalive `/tickle`,
6//! one at `/` from a passthrough cookie injection) end up as **two
7//! distinct entries**. When a typed API call later asks the jar what
8//! cookies to attach, both qualify and reqwest sends a `Cookie:`
9//! header with **two values for the same name** — which CPGateway
10//! treats as a session mismatch and rejects.
11//!
12//! Bezant-server is single-tenant against a single Gateway host, so we
13//! don't need RFC 6265's path/domain semantics. This jar keys cookies
14//! purely by name; setting `JSESSIONID=NEW` replaces `JSESSIONID=OLD`
15//! regardless of the path either was originally set on. The trade-off
16//! is that a Gateway with overlapping per-path cookies (rare in
17//! practice) would lose granularity — fine for our use case.
18
19use std::collections::HashMap;
20use std::fmt;
21use std::sync::Mutex;
22
23use reqwest::cookie::CookieStore;
24use reqwest::header::HeaderValue;
25use url::Url;
26
27/// Thread-safe, name-keyed cookie store. `set_cookies` parses each
28/// `Set-Cookie` value and stores `(name → value)` ignoring everything
29/// after the first `;` (attributes like `Path`, `HttpOnly`, `Secure`).
30#[derive(Default)]
31pub struct NameKeyedJar {
32    // Held only across sub-microsecond `HashMap` ops; never across
33    // `.await`. `std::sync::Mutex` is the right choice here —
34    // `tokio::sync::Mutex` would force `.await` and offer no benefit.
35    inner: Mutex<HashMap<String, String>>,
36}
37
38impl fmt::Debug for NameKeyedJar {
39    /// Print the cookie *names* and the entry count, never the values.
40    /// Cookie values frequently carry secrets (JSESSIONID, OAuth
41    /// tokens) and a derived `Debug` would leak them through every
42    /// `tracing::debug!(?jar)` call site.
43    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
44        let guard = self.inner.lock().unwrap_or_else(|e| e.into_inner());
45        let mut names: Vec<&str> = guard.keys().map(String::as_str).collect();
46        names.sort_unstable();
47        f.debug_struct("NameKeyedJar")
48            .field("len", &guard.len())
49            .field("names", &names)
50            .finish()
51    }
52}
53
54/// True when the byte is a CR/LF/NUL or other control char that
55/// `HeaderValue::from_str` would refuse, or a structural character
56/// that would split the cookie pair when serialised. Used to reject
57/// malformed Set-Cookie values before they poison the jar.
58fn is_unsafe_byte(b: u8) -> bool {
59    b < 0x20 || b == 0x7F
60}
61
62fn is_safe(s: &str) -> bool {
63    !s.bytes().any(is_unsafe_byte)
64}
65
66impl NameKeyedJar {
67    /// Create an empty jar.
68    #[must_use]
69    pub fn new() -> Self {
70        Self::default()
71    }
72
73    /// Insert raw `name=value` pairs, ignoring any cookie attributes.
74    /// Pairs with the same name replace previous values.
75    pub fn set_pairs<I, S>(&self, pairs: I)
76    where
77        I: IntoIterator<Item = S>,
78        S: AsRef<str>,
79    {
80        let mut guard = self.inner.lock().unwrap_or_else(|e| e.into_inner());
81        for raw in pairs {
82            let trimmed = raw.as_ref().trim();
83            if trimmed.is_empty() {
84                continue;
85            }
86            if let Some((name, value)) = trimmed.split_once('=') {
87                let name = name.trim();
88                let value = value.split(';').next().unwrap_or("").trim();
89                // Reject names/values containing control chars or
90                // delimiter characters that would either be rejected
91                // by `HeaderValue::from_str` later (silently dropping
92                // the whole `Cookie:` header) or break the
93                // `name1=v1; name2=v2` serialisation we emit from
94                // `cookies()`.
95                if name.is_empty() || !is_safe(name) || !is_safe(value) {
96                    continue;
97                }
98                guard.insert(name.to_owned(), value.to_owned());
99            }
100        }
101    }
102
103    /// Drop every entry. Useful when the upstream signals a session
104    /// reset.
105    pub fn clear(&self) {
106        self.inner.lock().unwrap_or_else(|e| e.into_inner()).clear();
107    }
108
109    /// Return the number of entries currently held — handy for diagnostics.
110    #[must_use]
111    pub fn len(&self) -> usize {
112        self.inner.lock().unwrap_or_else(|e| e.into_inner()).len()
113    }
114
115    /// Return `true` if the jar currently holds no entries.
116    #[must_use]
117    pub fn is_empty(&self) -> bool {
118        self.len() == 0
119    }
120
121    /// Snapshot of the current `(name, value)` pairs, sorted by name
122    /// for stable diagnostic output.
123    #[must_use]
124    pub fn snapshot(&self) -> Vec<(String, String)> {
125        let guard = self.inner.lock().unwrap_or_else(|e| e.into_inner());
126        let mut entries: Vec<_> = guard.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
127        entries.sort_by(|a, b| a.0.cmp(&b.0));
128        entries
129    }
130}
131
132impl CookieStore for NameKeyedJar {
133    fn set_cookies(&self, cookie_headers: &mut dyn Iterator<Item = &HeaderValue>, _url: &Url) {
134        let mut guard = self.inner.lock().unwrap_or_else(|e| e.into_inner());
135        for header in cookie_headers {
136            let Ok(s) = header.to_str() else { continue };
137            // Each `Set-Cookie` header carries one `name=value` plus
138            // attributes; we want only the first segment.
139            let primary = s.split(';').next().unwrap_or("").trim();
140            if primary.is_empty() {
141                continue;
142            }
143            if let Some((name, value)) = primary.split_once('=') {
144                let name = name.trim();
145                let value = value.trim();
146                // Same hardening as `set_pairs` — reject control chars
147                // and empty names so a single malformed Set-Cookie
148                // can't poison subsequent `Cookie:` serialisation.
149                if name.is_empty() || !is_safe(name) || !is_safe(value) {
150                    continue;
151                }
152                guard.insert(name.to_owned(), value.to_owned());
153            }
154        }
155    }
156
157    fn cookies(&self, _url: &Url) -> Option<HeaderValue> {
158        let guard = self.inner.lock().unwrap_or_else(|e| e.into_inner());
159        if guard.is_empty() {
160            return None;
161        }
162        let mut buf = String::new();
163        for (i, (name, value)) in guard.iter().enumerate() {
164            if i > 0 {
165                buf.push_str("; ");
166            }
167            buf.push_str(name);
168            buf.push('=');
169            buf.push_str(value);
170        }
171        HeaderValue::from_str(&buf).ok()
172    }
173}
174
175#[cfg(test)]
176mod tests {
177    use super::*;
178
179    fn url() -> Url {
180        Url::parse("https://localhost:5000/").unwrap()
181    }
182
183    #[test]
184    fn set_cookies_replaces_by_name() {
185        let jar = NameKeyedJar::new();
186        let h1 = HeaderValue::from_static("JSESSIONID=OLD; Path=/sso; HttpOnly");
187        let h2 = HeaderValue::from_static("JSESSIONID=NEW; Path=/; Secure");
188        jar.set_cookies(&mut [h1].iter(), &url());
189        jar.set_cookies(&mut [h2].iter(), &url());
190        assert_eq!(jar.snapshot(), vec![("JSESSIONID".into(), "NEW".into())]);
191    }
192
193    #[test]
194    fn set_pairs_replaces_by_name() {
195        let jar = NameKeyedJar::new();
196        jar.set_pairs(["X=A"]);
197        jar.set_pairs(["X=B"]);
198        assert_eq!(jar.snapshot(), vec![("X".into(), "B".into())]);
199    }
200
201    #[test]
202    fn set_pairs_strips_attributes() {
203        let jar = NameKeyedJar::new();
204        jar.set_pairs(["JSESSIONID=ABC; Domain=.ibkr.com; Secure"]);
205        assert_eq!(jar.snapshot(), vec![("JSESSIONID".into(), "ABC".into())]);
206    }
207
208    #[test]
209    fn cookies_combines_pairs_with_semicolon_separator() {
210        let jar = NameKeyedJar::new();
211        jar.set_pairs(["b=2", "a=1", "c=3"]);
212        // `HashMap` iteration order isn't stable, so assert presence
213        // + separator instead of full equality.
214        let s = jar.cookies(&url()).unwrap().to_str().unwrap().to_owned();
215        for needle in ["a=1", "b=2", "c=3"] {
216            assert!(s.contains(needle), "expected {needle} in {s:?}");
217        }
218        assert_eq!(s.matches("; ").count(), 2);
219    }
220
221    #[test]
222    fn cookies_single_entry_has_no_trailing_separator() {
223        let jar = NameKeyedJar::new();
224        jar.set_pairs(["only=one"]);
225        let header = jar.cookies(&url()).unwrap();
226        assert_eq!(header.to_str().unwrap(), "only=one");
227    }
228
229    #[test]
230    fn empty_jar_returns_none() {
231        let jar = NameKeyedJar::new();
232        assert!(jar.cookies(&url()).is_none());
233        assert!(jar.is_empty());
234        assert_eq!(jar.len(), 0);
235    }
236
237    #[test]
238    fn clear_empties_jar() {
239        let jar = NameKeyedJar::new();
240        jar.set_pairs(["a=1", "b=2"]);
241        assert_eq!(jar.len(), 2);
242        jar.clear();
243        assert!(jar.is_empty());
244        assert!(jar.cookies(&url()).is_none());
245    }
246
247    #[test]
248    fn snapshot_returns_sorted_by_name() {
249        let jar = NameKeyedJar::new();
250        jar.set_pairs(["c=3", "a=1", "b=2"]);
251        assert_eq!(
252            jar.snapshot(),
253            vec![
254                ("a".into(), "1".into()),
255                ("b".into(), "2".into()),
256                ("c".into(), "3".into()),
257            ]
258        );
259    }
260
261    #[test]
262    fn set_cookies_handles_iterator_of_multiple_values() {
263        let jar = NameKeyedJar::new();
264        let h1 = HeaderValue::from_static("a=1; Path=/");
265        let h2 = HeaderValue::from_static("b=2; HttpOnly");
266        let h3 = HeaderValue::from_static("c=3");
267        let mut iter = [&h1, &h2, &h3].into_iter();
268        jar.set_cookies(&mut iter, &url());
269        assert_eq!(jar.len(), 3);
270    }
271
272    #[test]
273    fn set_cookies_skips_non_ascii_header_values() {
274        let jar = NameKeyedJar::new();
275        // Non-UTF8 byte → `to_str()` fails and we skip silently.
276        let bad = HeaderValue::from_bytes(b"name=\xff").unwrap();
277        let good = HeaderValue::from_static("kept=yes");
278        jar.set_cookies(&mut [&bad, &good].into_iter(), &url());
279        assert_eq!(jar.snapshot(), vec![("kept".into(), "yes".into())]);
280    }
281
282    #[test]
283    fn set_pairs_skips_malformed_inputs() {
284        let jar = NameKeyedJar::new();
285        jar.set_pairs([
286            "",           // empty
287            "   ",        // whitespace only
288            "novalue",    // no `=`
289            "=onlyvalue", // empty name
290            "name=",      // empty value (allowed)
291            "n=a=b",      // value with `=`, kept as-is up to the first `;`
292        ]);
293        let snap = jar.snapshot();
294        // `name=` produces ("name", "") — empty value is legal cookie state.
295        // `n=a=b` produces ("n", "a=b") — split_once stops at first `=`.
296        assert!(snap.contains(&("name".into(), String::new())));
297        assert!(snap.contains(&("n".into(), "a=b".into())));
298        assert_eq!(snap.len(), 2);
299    }
300
301    #[test]
302    fn set_pairs_rejects_crlf_injection_in_value() {
303        let jar = NameKeyedJar::new();
304        // `HeaderValue` validation already blocks CR/LF from
305        // entering via `set_cookies`, but `set_pairs` takes a raw
306        // `&str` from a browser `Cookie:` header. If a future axum
307        // version relaxed `HeaderMap` validation, an embedded `\r\n`
308        // would land in the jar and the next `cookies()` call would
309        // build a `HeaderValue` containing control chars,
310        // `from_str` would error, and the entire `Cookie:` header
311        // would be silently dropped — costing the user their
312        // session. Defence-in-depth: reject at insertion.
313        jar.set_pairs(["X=foo\r\nInjected: yes", "kept=yes"]);
314        assert_eq!(jar.snapshot(), vec![("kept".into(), "yes".into())]);
315        let header = jar.cookies(&url()).unwrap();
316        assert_eq!(header.to_str().unwrap(), "kept=yes");
317    }
318
319    #[test]
320    fn rejects_control_chars_in_name() {
321        let jar = NameKeyedJar::new();
322        jar.set_pairs(["bad\rname=v", "ok=v"]);
323        assert_eq!(jar.snapshot(), vec![("ok".into(), "v".into())]);
324    }
325
326    #[test]
327    fn debug_does_not_leak_cookie_values() {
328        let jar = NameKeyedJar::new();
329        jar.set_pairs(["JSESSIONID=SECRET-VALUE-DO-NOT-LOG"]);
330        let rendered = format!("{jar:?}");
331        assert!(
332            !rendered.contains("SECRET-VALUE"),
333            "Debug must not leak cookie values, got: {rendered}"
334        );
335        assert!(rendered.contains("JSESSIONID"));
336        assert!(rendered.contains("len"));
337    }
338}