Skip to main content

session_guard/
cookie.rs

1//! The session cookie: configuration, `Set-Cookie` rendering and `Cookie` parsing.
2//!
3//! The cookie value is the bare token (43 base64url characters); nothing else is encoded in it.
4
5use std::fmt::Write as _;
6
7use crate::error::ConfigError;
8
9/// The cookie `SameSite` attribute.
10#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
11pub enum SameSite {
12    /// Never sent on cross-site requests, including top-level navigations.
13    Strict,
14    /// Sent on top-level cross-site navigations (GET), not on cross-site subrequests or POSTs.
15    #[default]
16    Lax,
17    /// Always sent. Requires `Secure`, and CSRF protection becomes your job.
18    None,
19}
20
21impl SameSite {
22    const fn as_str(self) -> &'static str {
23        match self {
24            SameSite::Strict => "Strict",
25            SameSite::Lax => "Lax",
26            SameSite::None => "None",
27        }
28    }
29}
30
31/// Session cookie attributes.
32///
33/// The defaults are the safest option for a same-origin app:
34///
35/// ```text
36/// Set-Cookie: __Host-sid=<43 chars>; Path=/; Max-Age=2592000; HttpOnly; Secure; SameSite=Lax
37/// ```
38///
39/// Browsers enforce the `__Host-` prefix: the cookie must be `Secure`, have `Path=/` and no
40/// `Domain`, so a sibling subdomain cannot set or overwrite it. [`CookieConfig::validate`] (run by
41/// [`GuardConfig`](crate::GuardConfig)'s `build`) rejects combinations browsers would drop.
42#[derive(Clone, Debug, PartialEq, Eq)]
43pub struct CookieConfig {
44    name: String,
45    path: String,
46    domain: Option<String>,
47    secure: bool,
48    http_only: bool,
49    same_site: SameSite,
50    persistent: bool,
51}
52
53impl Default for CookieConfig {
54    fn default() -> Self {
55        Self {
56            name: "__Host-sid".to_owned(),
57            path: "/".to_owned(),
58            domain: None,
59            secure: true,
60            http_only: true,
61            same_site: SameSite::Lax,
62            persistent: true,
63        }
64    }
65}
66
67impl CookieConfig {
68    /// Defaults with a different cookie name.
69    pub fn named(name: impl Into<String>) -> Self {
70        Self::default().name(name)
71    }
72
73    /// Cookie name. Keep the `__Host-` prefix unless you need `Domain` or a narrower `Path`.
74    #[must_use]
75    pub fn name(mut self, name: impl Into<String>) -> Self {
76        self.name = name.into();
77        self
78    }
79
80    /// `Path` attribute (default `/`).
81    #[must_use]
82    pub fn path(mut self, path: impl Into<String>) -> Self {
83        self.path = path.into();
84        self
85    }
86
87    /// `Domain` attribute (default none, i.e. host-only). Not allowed with `__Host-`.
88    #[must_use]
89    pub fn domain(mut self, domain: impl Into<String>) -> Self {
90        self.domain = Some(domain.into());
91        self
92    }
93
94    /// `Secure` attribute (default on). Only turn this off for plain-HTTP local development, and
95    /// then also drop the `__Host-` prefix.
96    #[must_use]
97    pub const fn secure(mut self, secure: bool) -> Self {
98        self.secure = secure;
99        self
100    }
101
102    /// `HttpOnly` attribute (default on), which hides the token from JavaScript.
103    #[must_use]
104    pub const fn http_only(mut self, http_only: bool) -> Self {
105        self.http_only = http_only;
106        self
107    }
108
109    /// `SameSite` attribute (default `Lax`).
110    #[must_use]
111    pub const fn same_site(mut self, same_site: SameSite) -> Self {
112        self.same_site = same_site;
113        self
114    }
115
116    /// Whether the cookie carries `Max-Age` (default on). When off, it is a browser-session
117    /// cookie that disappears when the browser closes; server-side expiry still applies.
118    #[must_use]
119    pub const fn persistent(mut self, persistent: bool) -> Self {
120        self.persistent = persistent;
121        self
122    }
123
124    /// The cookie name.
125    pub fn cookie_name(&self) -> &str {
126        &self.name
127    }
128
129    /// Checks the attributes against what browsers accept.
130    ///
131    /// # Errors
132    ///
133    /// [`ConfigError::InvalidCookie`] describing the first problem found.
134    pub fn validate(&self) -> Result<(), ConfigError> {
135        let invalid = ConfigError::InvalidCookie;
136        if self.name.is_empty() || !self.name.bytes().all(is_token_byte) {
137            return Err(invalid("name must be a non-empty HTTP token"));
138        }
139        if !self.path.starts_with('/') || !self.path.bytes().all(is_attr_byte) {
140            return Err(invalid(
141                "path must start with '/' and contain no ';' or control characters",
142            ));
143        }
144        if let Some(domain) = &self.domain
145            && (domain.is_empty() || !domain.bytes().all(is_attr_byte))
146        {
147            return Err(invalid(
148                "domain must be non-empty and contain no ';' or control characters",
149            ));
150        }
151        if self.name.starts_with("__Host-") {
152            if !self.secure {
153                return Err(invalid("__Host- cookies must be Secure"));
154            }
155            if self.domain.is_some() {
156                return Err(invalid("__Host- cookies must not have a Domain"));
157            }
158            if self.path != "/" {
159                return Err(invalid("__Host- cookies must have Path=/"));
160            }
161        }
162        if self.name.starts_with("__Secure-") && !self.secure {
163            return Err(invalid("__Secure- cookies must be Secure"));
164        }
165        if self.same_site == SameSite::None && !self.secure {
166            return Err(invalid("SameSite=None requires Secure"));
167        }
168        Ok(())
169    }
170
171    /// Renders a `Set-Cookie` value carrying `token`. `max_age_secs` is clamped at zero and only
172    /// emitted for persistent cookies.
173    pub fn set_cookie(&self, token: &str, max_age_secs: i64) -> String {
174        // Room for every attribute up front: growing the string would leave copies of the token
175        // in freed buffers that nothing wipes.
176        let capacity =
177            self.name.len() + token.len() + self.path.len() + self.domain.as_ref().map_or(0, String::len) + 96;
178        let mut out = String::with_capacity(capacity);
179        out.push_str(&self.name);
180        out.push('=');
181        out.push_str(token);
182        self.push_attributes(&mut out, self.persistent.then_some(max_age_secs.max(0)));
183        out
184    }
185
186    /// Renders a `Set-Cookie` value that deletes the cookie.
187    pub fn clear_cookie(&self) -> String {
188        let mut out = format!("{}=", self.name);
189        self.push_attributes(&mut out, Some(0));
190        out
191    }
192
193    fn push_attributes(&self, out: &mut String, max_age: Option<i64>) {
194        let _ = write!(out, "; Path={}", self.path);
195        if let Some(domain) = &self.domain {
196            let _ = write!(out, "; Domain={domain}");
197        }
198        if let Some(max_age) = max_age {
199            let _ = write!(out, "; Max-Age={max_age}");
200        }
201        if self.http_only {
202            out.push_str("; HttpOnly");
203        }
204        if self.secure {
205            out.push_str("; Secure");
206        }
207        let _ = write!(out, "; SameSite={}", self.same_site.as_str());
208    }
209
210    /// Finds this cookie's value in one `Cookie` header value. Takes the first occurrence (the
211    /// browser sends the most specific path first). Returns the raw value, unvalidated.
212    pub fn find_in<'a>(&self, cookie_header: &'a str) -> Option<&'a str> {
213        find_cookie(cookie_header, &self.name)
214    }
215}
216
217/// Finds `name` in a `Cookie` header value. Never panics on arbitrary input.
218pub fn find_cookie<'a>(cookie_header: &'a str, name: &str) -> Option<&'a str> {
219    cookie_header.split(';').find_map(|pair| {
220        let (key, value) = pair.split_once('=')?;
221        if key.trim() != name {
222            return None;
223        }
224        let value = value.trim();
225        let value = value
226            .strip_prefix('"')
227            .and_then(|v| v.strip_suffix('"'))
228            .unwrap_or(value);
229        Some(value)
230    })
231}
232
233const fn is_token_byte(b: u8) -> bool {
234    b.is_ascii_alphanumeric()
235        || matches!(
236            b,
237            b'!' | b'#' | b'$' | b'%' | b'&' | b'\'' | b'*' | b'+' | b'-' | b'.' | b'^' | b'_' | b'`' | b'|' | b'~'
238        )
239}
240
241const fn is_attr_byte(b: u8) -> bool {
242    b.is_ascii_graphic() && b != b';'
243}
244
245#[cfg(test)]
246mod tests {
247    use proptest::prelude::*;
248
249    use super::*;
250
251    const TOKEN: &str = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
252
253    #[test]
254    fn default_set_cookie_is_host_prefixed_and_locked_down() {
255        let c = CookieConfig::default();
256        assert!(c.validate().is_ok());
257        assert_eq!(
258            c.set_cookie(TOKEN, 2_592_000),
259            format!("__Host-sid={TOKEN}; Path=/; Max-Age=2592000; HttpOnly; Secure; SameSite=Lax")
260        );
261        assert_eq!(
262            c.clear_cookie(),
263            "__Host-sid=; Path=/; Max-Age=0; HttpOnly; Secure; SameSite=Lax"
264        );
265    }
266
267    #[test]
268    fn custom_attributes_render() {
269        let c = CookieConfig::named("sid")
270            .domain("example.com")
271            .path("/app")
272            .same_site(SameSite::Strict)
273            .http_only(false)
274            .persistent(false);
275        assert!(c.validate().is_ok());
276        assert_eq!(
277            c.set_cookie("v", 10),
278            "sid=v; Path=/app; Domain=example.com; Secure; SameSite=Strict"
279        );
280        assert_eq!(
281            c.clear_cookie(),
282            "sid=; Path=/app; Domain=example.com; Max-Age=0; Secure; SameSite=Strict"
283        );
284    }
285
286    #[test]
287    fn negative_max_age_is_clamped() {
288        assert!(CookieConfig::default().set_cookie("v", -5).contains("Max-Age=0"));
289    }
290
291    #[test]
292    fn browser_rejected_combinations_fail_validation() {
293        let bad = [
294            CookieConfig::default().secure(false),
295            CookieConfig::default().domain("example.com"),
296            CookieConfig::default().path("/app"),
297            CookieConfig::named("__Secure-sid").secure(false),
298            CookieConfig::named("sid").secure(false).same_site(SameSite::None),
299            CookieConfig::named(""),
300            CookieConfig::named("s id"),
301            CookieConfig::named("s;id"),
302            CookieConfig::named("sid").path("app"),
303            CookieConfig::named("sid").path("/a;b"),
304            CookieConfig::named("sid").domain(""),
305            CookieConfig::named("sid").domain("a.com; Secure"),
306        ];
307        for c in bad {
308            assert!(c.validate().is_err(), "{c:?}");
309        }
310        assert!(CookieConfig::named("sid").secure(false).validate().is_ok());
311    }
312
313    #[test]
314    fn finds_cookie_values() {
315        let c = CookieConfig::default();
316        assert_eq!(c.find_in("a=1; __Host-sid=abc; b=2"), Some("abc"));
317        assert_eq!(c.find_in("__Host-sid=first;__Host-sid=second"), Some("first"));
318        assert_eq!(c.find_in("__Host-sid=\"q\""), Some("q"));
319        assert_eq!(c.find_in(" __Host-sid = x "), Some("x"));
320        assert_eq!(c.find_in("x__Host-sid=1; __Host-sidx=2"), None);
321        assert_eq!(c.find_in(""), None);
322        assert_eq!(c.find_in("__Host-sid"), None);
323        assert_eq!(c.find_in("__Host-sid="), Some(""));
324    }
325
326    proptest! {
327        #[test]
328        fn find_cookie_never_panics(header in ".{0,200}", name in "[a-z_-]{1,12}") {
329            let _ = find_cookie(&header, &name);
330        }
331
332        #[test]
333        fn rendered_cookie_is_found_again(token in "[A-Za-z0-9_-]{43}") {
334            let c = CookieConfig::default();
335            let set = c.set_cookie(&token, 60);
336            let pair = set.split(';').next().unwrap();
337            let header = format!("other=1; {pair}");
338            prop_assert_eq!(c.find_in(&header), Some(token.as_str()));
339        }
340    }
341}