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);
}
}