Skip to main content

churust_core/
cookie.rs

1//! Cookies: reading them off a request and building `Set-Cookie`.
2//!
3//! Defaults are the safe ones. A cookie is a credential until proven otherwise,
4//! so [`Cookie::new`] sets `HttpOnly`, `SameSite=Lax` and `Path=/`; opt out
5//! deliberately rather than opt in.
6
7use std::fmt::Write as _;
8
9/// The `SameSite` attribute.
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum SameSite {
12    /// Never sent cross-site.
13    Strict,
14    /// Sent on top-level navigations. The default, and what most sessions want.
15    Lax,
16    /// Sent on every cross-site request. Requires `Secure`, per the spec.
17    None,
18}
19
20impl SameSite {
21    fn as_str(self) -> &'static str {
22        match self {
23            SameSite::Strict => "Strict",
24            SameSite::Lax => "Lax",
25            SameSite::None => "None",
26        }
27    }
28}
29
30/// A cookie to send with [`Response::with_cookie`](crate::Response::with_cookie).
31#[derive(Debug, Clone)]
32pub struct Cookie {
33    name: String,
34    value: String,
35    path: Option<String>,
36    domain: Option<String>,
37    max_age: Option<i64>,
38    secure: bool,
39    http_only: bool,
40    same_site: Option<SameSite>,
41}
42
43impl Cookie {
44    /// A cookie with safe defaults: `HttpOnly`, `SameSite=Lax`, `Path=/`.
45    pub fn new(name: impl Into<String>, value: impl Into<String>) -> Self {
46        Self {
47            name: name.into(),
48            value: value.into(),
49            path: Some("/".into()),
50            domain: None,
51            max_age: None,
52            secure: false,
53            http_only: true,
54            same_site: Some(SameSite::Lax),
55        }
56    }
57
58    /// A cookie that deletes an existing one of the same name.
59    ///
60    /// `Path` must match the cookie being removed, so set it if the original
61    /// used something other than `/`.
62    pub fn removal(name: impl Into<String>) -> Self {
63        let mut c = Self::new(name, "");
64        c.max_age = Some(0);
65        c
66    }
67
68    /// Set `Path`. `None` omits it.
69    pub fn path(mut self, p: impl Into<String>) -> Self {
70        self.path = Some(p.into());
71        self
72    }
73    /// Set `Domain`.
74    pub fn domain(mut self, d: impl Into<String>) -> Self {
75        self.domain = Some(d.into());
76        self
77    }
78    /// Set `Max-Age` in seconds.
79    pub fn max_age(mut self, secs: i64) -> Self {
80        self.max_age = Some(secs);
81        self
82    }
83    /// Set `Secure` β€” the cookie is only sent over HTTPS.
84    pub fn secure(mut self, yes: bool) -> Self {
85        self.secure = yes;
86        self
87    }
88    /// Set `HttpOnly` β€” script cannot read the cookie. On by default.
89    pub fn http_only(mut self, yes: bool) -> Self {
90        self.http_only = yes;
91        self
92    }
93    /// Set `SameSite`. Defaults to `Lax`.
94    pub fn same_site(mut self, s: SameSite) -> Self {
95        self.same_site = Some(s);
96        self
97    }
98
99    /// Render the `Set-Cookie` header value.
100    pub fn to_header_value(&self) -> String {
101        let mut out = String::new();
102        let _ = write!(out, "{}={}", encode(&self.name), encode(&self.value));
103        if let Some(p) = &self.path {
104            let _ = write!(out, "; Path={}", attribute_value(p));
105        }
106        if let Some(d) = &self.domain {
107            let _ = write!(out, "; Domain={}", attribute_value(d));
108        }
109        if let Some(m) = self.max_age {
110            let _ = write!(out, "; Max-Age={m}");
111        }
112        if self.secure {
113            out.push_str("; Secure");
114        }
115        if self.http_only {
116            out.push_str("; HttpOnly");
117        }
118        if let Some(s) = self.same_site {
119            let _ = write!(out, "; SameSite={}", s.as_str());
120        }
121        out
122    }
123}
124
125/// Strip anything from an attribute value that would change the structure of
126/// the header.
127///
128/// RFC 6265's `av-octet` is any CHAR except CTLs and `;`, and both exclusions
129/// matter here. A `;` starts a new attribute, so an application scoping a
130/// cookie with `.path(format!("/u/{slug}"))` and a hostile slug could append
131/// `Max-Age=0` and delete the victim's session β€” or set `Secure`, a `Domain`,
132/// or a second `Path`. A CR or LF is header injection outright, and previously
133/// made `HeaderValue::from_str` reject the whole value, so the `Set-Cookie` was
134/// dropped silently and the session was simply never issued.
135///
136/// Stripping rather than refusing: dropping the attribute would *widen* the
137/// cookie's scope, which is the more dangerous failure of the two.
138fn attribute_value(raw: &str) -> String {
139    raw.chars()
140        .filter(|c| *c != ';' && !c.is_control())
141        .collect()
142}
143
144/// Percent-encode anything that would change the meaning of the header.
145///
146/// A raw `;` in a value would forge a second attribute, and a raw `,` can split
147/// a header. Encoding is what keeps a value a value.
148fn encode(s: &str) -> String {
149    let mut out = String::with_capacity(s.len());
150    for b in s.bytes() {
151        match b {
152            // The cookie-value grammar also permits '%', but we decode on read,
153            // so letting a literal '%' through would make the round trip lossy:
154            // a stored "%3D" would come back as "=". Escape it.
155            b'%' => out.push_str("%25"),
156            b'!' | b'#'..=b'+' | b'-'..=b':' | b'<'..=b'[' | b']'..=b'~' => out.push(b as char),
157            _ => {
158                let _ = write!(out, "%{b:02X}");
159            }
160        }
161    }
162    out
163}
164
165/// Decode a percent-encoded cookie value. Invalid escapes are left as-is: a
166/// cookie is not a security boundary the way a path is, and dropping the value
167/// entirely would be worse for the caller than returning it unchanged.
168pub(crate) fn decode(s: &str) -> String {
169    if !s.contains('%') {
170        return s.to_string();
171    }
172    let bytes = s.as_bytes();
173    let mut out = Vec::with_capacity(bytes.len());
174    let mut i = 0;
175    while i < bytes.len() {
176        if bytes[i] == b'%' && i + 2 < bytes.len() {
177            let hi = (bytes[i + 1] as char).to_digit(16);
178            let lo = (bytes[i + 2] as char).to_digit(16);
179            if let (Some(h), Some(l)) = (hi, lo) {
180                out.push((h * 16 + l) as u8);
181                i += 3;
182                continue;
183            }
184        }
185        out.push(bytes[i]);
186        i += 1;
187    }
188    String::from_utf8_lossy(&out).into_owned()
189}
190
191/// Find `name` in a `Cookie:` header value.
192pub(crate) fn find<'a>(header: &'a str, name: &str) -> Option<&'a str> {
193    header.split(';').find_map(|pair| {
194        let (k, v) = pair.split_once('=')?;
195        (k.trim() == name).then_some(v.trim())
196    })
197}
198
199#[cfg(test)]
200mod tests {
201    use super::*;
202
203    #[test]
204    fn encodes_separators() {
205        assert_eq!(encode("a;b"), "a%3Bb");
206        assert_eq!(encode("a b"), "a%20b");
207        assert_eq!(encode("plain"), "plain");
208    }
209
210    #[test]
211    fn round_trips() {
212        for v in ["a;b c", "100%", "a%3Db", "plain", "", "ΓΌ"] {
213            assert_eq!(decode(&encode(v)), v, "round trip failed for {v:?}");
214        }
215    }
216
217    #[test]
218    fn finds_a_named_pair() {
219        assert_eq!(find("a=1; b=2", "b"), Some("2"));
220        assert_eq!(find("a=1", "missing"), None);
221    }
222}