Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/edda

crates/nornir-identity-session/src/cookie.rs

//! **The session cookie, `__Host-` and HttpOnly** (A34-38, moved from
//! gunnar-ui's `auth/mod.rs`): one renderer and one reader for every console.
//!
//! **`__Host-` is load-bearing.** A browser refuses to STORE a cookie with this
//! prefix unless it is `Secure`, has `Path=/` and carries no `Domain`. That
//! last one is the point: without the prefix, any sister host under a shared
//! registrable domain could set a cookie of the same name with
//! `Domain=.example.com`, and the console's own `Path=/` cookie could neither
//! stop it nor tell it apart. `HttpOnly` keeps it out of every script's reach
//! (Holger's console kept its bearer in sessionStorage, readable by any script).

/// Why a cookie name was refused.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NotHostPrefixed(pub String);

impl std::fmt::Display for NotHostPrefixed {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(
            f,
            "the session cookie {:?} is not `__Host-` prefixed, so a sister domain could set one of the same name",
            self.0
        )
    }
}

impl std::error::Error for NotHostPrefixed {}

/// A session cookie's name, checked once: `__Host-` and a cookie token.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HostCookie(String);

impl HostCookie {
    /// The cookie `name`, refused unless it is `__Host-` prefixed and a token.
    pub fn new(name: &str) -> Result<Self, NotHostPrefixed> {
        let token = |b: u8| b.is_ascii_alphanumeric() || b"!#$%&'*+-.^_`|~".contains(&b);
        if !name.starts_with("__Host-") || name.len() == "__Host-".len() || !name.bytes().all(token) {
            return Err(NotHostPrefixed(name.to_owned()));
        }
        Ok(HostCookie(name.to_owned()))
    }

    /// The name.
    pub fn name(&self) -> &str {
        &self.0
    }

    /// **The `Set-Cookie` value** for `value`, living `max_age` seconds (0
    /// clears it). `Secure` holds even in development: browsers treat
    /// `http://localhost` as a trustworthy origin, so the cookie is still set.
    pub fn set(&self, value: &str, max_age: i64) -> String {
        format!("{}={value}; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age={max_age}", self.0)
    }

    /// **The cookie's value, from every `Cookie` header line.** Two cookies of
    /// this name are `None`, not a choice between them: a browser sends every
    /// cookie that matches, in an order the RFC leaves to it, so a planted one
    /// could be sent ahead of the real one and silently win (session fixation
    /// decided by cookie ordering). All lines are read, not just the first: a
    /// second header line is the other half of the same trick.
    pub fn value_in<'a>(&self, cookie_lines: impl IntoIterator<Item = &'a str>) -> Option<String> {
        let mut found = cookie_lines
            .into_iter()
            .flat_map(|line| line.split(';'))
            .filter_map(|pair| pair.trim().split_once('='))
            .filter(|(name, _)| *name == self.0)
            .map(|(_, value)| value.to_owned());
        let first = found.next()?;
        found.next().is_none().then_some(first)
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn the_session_cookie_is_host_prefixed_and_meets_what_the_prefix_requires() {
        assert!(HostCookie::new("gunnar_ui_session").is_err(), "no prefix");
        assert!(HostCookie::new("__Host-").is_err(), "a bare prefix");
        assert!(HostCookie::new("__Host-a b").is_err(), "not a token");
        let c = HostCookie::new("__Host-console_session").unwrap();
        let rendered = c.set("abc", 42);
        assert!(rendered.starts_with("__Host-console_session=abc;"), "{rendered}");
        for part in ["; HttpOnly", "; Secure", "; Path=/", "; SameSite=Strict", "; Max-Age=42"] {
            assert!(rendered.contains(part), "{part} in {rendered}");
        }
        assert!(!rendered.to_ascii_lowercase().contains("domain="), "{rendered}");
        let cleared = c.set("", 0);
        assert!(cleared.starts_with("__Host-console_session=;") && cleared.contains("Max-Age=0"), "{cleared}");
    }

    #[test]
    fn two_cookies_of_the_name_are_none_across_every_header_line() {
        let c = HostCookie::new("__Host-s").unwrap();
        assert_eq!(c.value_in(["a=1; __Host-s=abc.def; b=2"]).as_deref(), Some("abc.def"));
        assert_eq!(c.value_in(["__Host-s=one; __Host-s=two"]), None, "two in one line");
        assert_eq!(c.value_in(["__Host-s=one", "x=y; __Host-s=two"]), None, "two across lines");
        assert_eq!(c.value_in(["__Host-sx=one"]), None, "another name");
        assert_eq!(c.value_in(Vec::<&str>::new()), None);
    }
}