Skip to main content

rainier_http/
cookie.rs

1//! Cookies — [`Cookie`], [`SameSite`] and the `Cookie:` / `Set-Cookie:`
2//! encodings.
3
4use std::collections::HashMap;
5use std::fmt::Write as _;
6
7use chrono::{DateTime, Utc};
8use percent_encoding::{percent_decode_str, utf8_percent_encode, AsciiSet, CONTROLS};
9
10/// Characters escaped in cookie values.
11///
12/// The RFC 6265 grammar forbids control characters, whitespace, quotes,
13/// commas, semicolons and backslashes in a cookie value. Percent-encoding just
14/// those keeps ordinary values (including JSON-ish ones) readable in a browser
15/// inspector, rather than turning every value into an unreadable blob.
16const COOKIE_VALUE: &AsciiSet =
17    &CONTROLS.add(b' ').add(b'"').add(b',').add(b';').add(b'\\').add(b'%');
18
19/// A cookie's cross-site policy.
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
21pub enum SameSite {
22    /// Sent on same-site requests and top-level cross-site navigations. The
23    /// default, and the right choice for a session cookie.
24    #[default]
25    Lax,
26    /// Never sent cross-site.
27    Strict,
28    /// Always sent. Browsers reject this unless `Secure` is also set, so
29    /// [`Cookie::same_site`] turns `Secure` on with it.
30    None,
31}
32
33impl SameSite {
34    fn as_str(self) -> &'static str {
35        match self {
36            SameSite::Lax => "Lax",
37            SameSite::Strict => "Strict",
38            SameSite::None => "None",
39        }
40    }
41}
42
43/// A cookie to send, or one that arrived.
44#[derive(Debug, Clone, PartialEq)]
45pub struct Cookie {
46    name: String,
47    value: String,
48    path: Option<String>,
49    domain: Option<String>,
50    max_age: Option<i64>,
51    expires: Option<DateTime<Utc>>,
52    secure: bool,
53    http_only: bool,
54    same_site: Option<SameSite>,
55}
56
57impl Cookie {
58    /// A cookie with sensible defaults: path `/`, `HttpOnly`, `SameSite=Lax`.
59    ///
60    /// Those defaults are the safe ones rather than the empty ones. A cookie
61    /// this framework sets is a session or CSRF cookie until proven otherwise,
62    /// and both want to be invisible to scripts and not sent cross-site. Opt
63    /// out explicitly with [`http_only(false)`](Self::http_only).
64    pub fn new(name: impl Into<String>, value: impl Into<String>) -> Self {
65        Self {
66            name: name.into(),
67            value: value.into(),
68            path: Some("/".to_string()),
69            domain: None,
70            max_age: None,
71            expires: None,
72            secure: false,
73            http_only: true,
74            same_site: Some(SameSite::Lax),
75        }
76    }
77
78    /// A cookie that instructs the browser to delete the named one.
79    pub fn removal(name: impl Into<String>) -> Self {
80        Self::new(name, "").max_age(0).expires(DateTime::UNIX_EPOCH)
81    }
82
83    /// The cookie's name.
84    pub fn name(&self) -> &str {
85        &self.name
86    }
87
88    /// The cookie's value.
89    pub fn value(&self) -> &str {
90        &self.value
91    }
92
93    /// Restrict the cookie to a path prefix.
94    pub fn path(mut self, path: impl Into<String>) -> Self {
95        self.path = Some(path.into());
96        self
97    }
98
99    /// Scope the cookie to a domain (and its subdomains).
100    pub fn domain(mut self, domain: impl Into<String>) -> Self {
101        self.domain = Some(domain.into());
102        self
103    }
104
105    /// Expire the cookie after `seconds`. `0` deletes it immediately.
106    pub fn max_age(mut self, seconds: i64) -> Self {
107        self.max_age = Some(seconds);
108        self
109    }
110
111    /// Expire the cookie at an absolute time.
112    pub fn expires(mut self, at: DateTime<Utc>) -> Self {
113        self.expires = Some(at);
114        self
115    }
116
117    /// Only send the cookie over HTTPS.
118    pub fn secure(mut self, secure: bool) -> Self {
119        self.secure = secure;
120        self
121    }
122
123    /// Hide the cookie from JavaScript. On by default.
124    pub fn http_only(mut self, http_only: bool) -> Self {
125        self.http_only = http_only;
126        self
127    }
128
129    /// Set the cross-site policy.
130    ///
131    /// [`SameSite::None`] also forces `Secure`, because every current browser
132    /// rejects `SameSite=None` without it — silently, which would be a
133    /// miserable thing to debug.
134    pub fn same_site(mut self, same_site: SameSite) -> Self {
135        if same_site == SameSite::None {
136            self.secure = true;
137        }
138        self.same_site = Some(same_site);
139        self
140    }
141
142    /// Render the `Set-Cookie` header value.
143    pub fn to_set_cookie(&self) -> String {
144        let mut out = String::with_capacity(self.name.len() + self.value.len() + 48);
145        let _ = write!(
146            out,
147            "{}={}",
148            utf8_percent_encode(&self.name, COOKIE_VALUE),
149            utf8_percent_encode(&self.value, COOKIE_VALUE)
150        );
151
152        if let Some(path) = &self.path {
153            let _ = write!(out, "; Path={path}");
154        }
155        if let Some(domain) = &self.domain {
156            let _ = write!(out, "; Domain={domain}");
157        }
158        if let Some(max_age) = self.max_age {
159            let _ = write!(out, "; Max-Age={max_age}");
160        }
161        if let Some(expires) = self.expires {
162            // RFC 7231 IMF-fixdate, e.g. "Thu, 01 Jan 1970 00:00:00 GMT".
163            let _ = write!(out, "; Expires={}", expires.format("%a, %d %b %Y %H:%M:%S GMT"));
164        }
165        if self.secure {
166            out.push_str("; Secure");
167        }
168        if self.http_only {
169            out.push_str("; HttpOnly");
170        }
171        if let Some(same_site) = self.same_site {
172            let _ = write!(out, "; SameSite={}", same_site.as_str());
173        }
174        out
175    }
176}
177
178/// Parse a request's `Cookie:` header into name/value pairs.
179///
180/// Attributes never appear on the request side — a browser sends only
181/// `name=value` pairs — so this is deliberately simpler than parsing
182/// `Set-Cookie`.
183pub fn parse_cookie_header(header: &str) -> HashMap<String, String> {
184    let mut cookies = HashMap::new();
185    for pair in header.split(';') {
186        let pair = pair.trim();
187        if pair.is_empty() {
188            continue;
189        }
190        let (name, value) = match pair.split_once('=') {
191            Some((name, value)) => (name.trim(), value.trim()),
192            // A bare token with no `=` is a valueless cookie; keep it rather
193            // than drop data the application might be looking for.
194            None => (pair, ""),
195        };
196        cookies.insert(decode(name), decode(value));
197    }
198    cookies
199}
200
201fn decode(raw: &str) -> String {
202    // Quoted-string form: `name="value"`.
203    let trimmed = raw.strip_prefix('"').and_then(|r| r.strip_suffix('"')).unwrap_or(raw);
204    percent_decode_str(trimmed).decode_utf8_lossy().into_owned()
205}
206
207#[cfg(test)]
208mod tests {
209    use super::*;
210
211    #[test]
212    fn defaults_are_the_safe_ones() {
213        let rendered = Cookie::new("session", "abc").to_set_cookie();
214        assert!(rendered.starts_with("session=abc"));
215        assert!(rendered.contains("; Path=/"));
216        assert!(rendered.contains("; HttpOnly"));
217        assert!(rendered.contains("; SameSite=Lax"));
218        assert!(!rendered.contains("Secure"));
219    }
220
221    #[test]
222    fn attributes_render() {
223        let rendered = Cookie::new("a", "b")
224            .path("/admin")
225            .domain("example.com")
226            .max_age(3600)
227            .secure(true)
228            .http_only(false)
229            .same_site(SameSite::Strict)
230            .to_set_cookie();
231
232        assert!(rendered.contains("; Path=/admin"));
233        assert!(rendered.contains("; Domain=example.com"));
234        assert!(rendered.contains("; Max-Age=3600"));
235        assert!(rendered.contains("; Secure"));
236        assert!(!rendered.contains("HttpOnly"));
237        assert!(rendered.contains("; SameSite=Strict"));
238    }
239
240    #[test]
241    fn same_site_none_forces_secure() {
242        let rendered = Cookie::new("a", "b").same_site(SameSite::None).to_set_cookie();
243        assert!(rendered.contains("; SameSite=None"));
244        assert!(rendered.contains("; Secure"), "browsers drop SameSite=None without Secure");
245    }
246
247    #[test]
248    fn removal_expires_in_the_past() {
249        let rendered = Cookie::removal("session").to_set_cookie();
250        assert!(rendered.starts_with("session="));
251        assert!(rendered.contains("; Max-Age=0"));
252        assert!(rendered.contains("Expires=Thu, 01 Jan 1970 00:00:00 GMT"));
253    }
254
255    #[test]
256    fn values_needing_escapes_are_encoded() {
257        let rendered = Cookie::new("data", "a;b c").to_set_cookie();
258        assert!(rendered.starts_with("data=a%3Bb%20c"), "{rendered}");
259    }
260
261    #[test]
262    fn parses_a_request_cookie_header() {
263        let cookies = parse_cookie_header("session=abc; theme=dark; ");
264        assert_eq!(cookies.get("session").map(String::as_str), Some("abc"));
265        assert_eq!(cookies.get("theme").map(String::as_str), Some("dark"));
266        assert_eq!(cookies.len(), 2);
267    }
268
269    #[test]
270    fn parsing_reverses_the_encoding() {
271        let rendered = Cookie::new("data", "a;b c").to_set_cookie();
272        let pair = rendered.split(';').next().unwrap();
273        assert_eq!(parse_cookie_header(pair).get("data").map(String::as_str), Some("a;b c"));
274    }
275
276    #[test]
277    fn handles_quoted_and_valueless_cookies() {
278        let cookies = parse_cookie_header("quoted=\"hello\"; bare");
279        assert_eq!(cookies.get("quoted").map(String::as_str), Some("hello"));
280        assert_eq!(cookies.get("bare").map(String::as_str), Some(""));
281    }
282
283    #[test]
284    fn an_empty_header_yields_nothing() {
285        assert!(parse_cookie_header("").is_empty());
286        assert!(parse_cookie_header("  ;  ").is_empty());
287    }
288}