use serde_json::Value;
const MAX_COOKIE_VALUE_BYTES: usize = 4096;
fn pairs(jar: &str) -> impl Iterator<Item = (&str, &str)> {
jar.split(';').filter_map(|pair| {
let (name, value) = pair.split_once('=')?;
let name = name.trim();
if name.is_empty() {
return None;
}
let value = unquote(value.trim());
if value.is_empty() || value.len() > MAX_COOKIE_VALUE_BYTES {
return None;
}
Some((name, value))
})
}
fn unquote(value: &str) -> &str {
value
.strip_prefix('"')
.and_then(|v| v.strip_suffix('"'))
.unwrap_or(value)
}
pub(crate) fn lookup<'a>(jar: impl IntoIterator<Item = &'a str>, name: &str) -> Option<String> {
jar.into_iter()
.flat_map(pairs)
.find(|(n, _)| *n == name)
.map(|(_, v)| v.to_string())
}
pub(crate) fn collect<'a>(
jar: impl IntoIterator<Item = &'a str>,
allowlist: &[String],
) -> serde_json::Map<String, Value> {
let mut out = serde_json::Map::new();
for (name, value) in jar.into_iter().flat_map(pairs) {
if out.contains_key(name) {
continue;
}
if allowlist.iter().any(|a| a == name) {
out.insert(name.to_string(), Value::String(value.to_string()));
}
}
out
}
pub(crate) fn format_set_cookie(spec: &Value) -> Result<String, String> {
let obj = spec.as_object().ok_or("cookie must be an object")?;
let name = obj
.get("name")
.and_then(Value::as_str)
.ok_or("cookie needs a string 'name'")?;
if name.is_empty() || !name.bytes().all(is_token_byte) {
return Err(format!("cookie name {name:?} is not a valid token"));
}
let value = obj
.get("value")
.and_then(Value::as_str)
.ok_or("cookie needs a string 'value'")?;
if !value.bytes().all(is_value_byte) {
return Err(format!("cookie {name:?} has an unencodable value"));
}
let mut out = format!("{name}={value}");
for (key, attr) in [("path", "Path"), ("domain", "Domain")] {
if let Some(v) = obj.get(key) {
let v = v
.as_str()
.ok_or_else(|| format!("cookie {name:?}: '{key}' must be a string"))?;
if !is_attribute_safe(v) {
return Err(format!("cookie {name:?}: '{key}' has an unsafe value"));
}
out.push_str(&format!("; {attr}={v}"));
}
}
if let Some(v) = obj.get("max_age") {
let secs = v
.as_i64()
.ok_or_else(|| format!("cookie {name:?}: 'max_age' must be an integer"))?;
out.push_str(&format!("; Max-Age={secs}"));
}
if let Some(v) = obj.get("expires") {
let v = v
.as_str()
.ok_or_else(|| format!("cookie {name:?}: 'expires' must be a string"))?;
if !is_attribute_safe(v) {
return Err(format!("cookie {name:?}: 'expires' has an unsafe value"));
}
out.push_str(&format!("; Expires={v}"));
}
if let Some(v) = obj.get("same_site") {
let v = v
.as_str()
.ok_or_else(|| format!("cookie {name:?}: 'same_site' must be a string"))?;
let canonical = match v.to_ascii_lowercase().as_str() {
"strict" => "Strict",
"lax" => "Lax",
"none" => "None",
other => {
return Err(format!(
"cookie {name:?}: 'same_site' must be Strict, Lax or None, got {other:?}"
));
}
};
out.push_str(&format!("; SameSite={canonical}"));
}
for (key, attr) in [("http_only", "HttpOnly"), ("secure", "Secure")] {
match obj.get(key) {
None | Some(Value::Bool(false)) => {}
Some(Value::Bool(true)) => out.push_str(&format!("; {attr}")),
Some(_) => return Err(format!("cookie {name:?}: '{key}' must be a boolean")),
}
}
Ok(out)
}
fn is_token_byte(b: u8) -> bool {
b.is_ascii_graphic() && !br#"()<>@,;:\"/[]?={}"#.contains(&b)
}
fn is_value_byte(b: u8) -> bool {
b.is_ascii_graphic() && !matches!(b, b',' | b';' | b'\\' | b'"')
}
fn is_attribute_safe(v: &str) -> bool {
!v.bytes().any(|b| matches!(b, b';' | b'\r' | b'\n' | 0))
}
#[cfg(test)]
mod tests {
use super::*;
fn one(jar: &str, name: &str) -> Option<String> {
lookup([jar], name)
}
#[test]
fn a_plain_jar_parses() {
let jar = "a=1; b=2; browser_uuid=abc-123";
assert_eq!(one(jar, "a").as_deref(), Some("1"));
assert_eq!(one(jar, "browser_uuid").as_deref(), Some("abc-123"));
assert_eq!(one(jar, "missing"), None);
}
#[test]
fn quoted_values_and_loose_whitespace_are_handled() {
assert_eq!(one(r#"sid="abc""#, "sid").as_deref(), Some("abc"));
assert_eq!(one("sid = abc ", "sid").as_deref(), Some("abc"));
assert_eq!(one(" sid=abc ; b=2", "sid").as_deref(), Some("abc"));
assert_eq!(one(r#"sid="abc"#, "sid").as_deref(), Some(r#""abc"#));
}
#[test]
fn a_jar_split_across_headers_is_searched_whole() {
assert_eq!(lookup(["a=1", "b=2"], "b").as_deref(), Some("2"));
}
#[test]
fn base64_padding_survives() {
assert_eq!(one("t=YWJjZA==", "t").as_deref(), Some("YWJjZA=="));
}
#[test]
fn name_matching_is_case_sensitive() {
assert_eq!(one("SID=abc", "sid"), None);
assert_eq!(one("SID=abc", "SID").as_deref(), Some("abc"));
}
#[test]
fn the_first_duplicate_wins() {
assert_eq!(one("a=first; a=second", "a").as_deref(), Some("first"));
}
#[test]
fn empty_and_oversized_values_are_absent() {
assert_eq!(one("a=; b=2", "a"), None);
assert_eq!(one("a=\"\"; b=2", "a"), None);
let huge = format!("a={}", "x".repeat(MAX_COOKIE_VALUE_BYTES + 1));
assert_eq!(one(&huge, "a"), None);
}
#[test]
fn a_malformed_pair_is_skipped_not_fatal() {
assert_eq!(one("novalue; =orphan; a=1", "a").as_deref(), Some("1"));
}
#[test]
fn collect_takes_only_the_allowlist() {
let jar = "browser_uuid=abc; session=secret; other=x";
let out = collect([jar], &["browser_uuid".to_string(), "absent".to_string()]);
assert_eq!(out.len(), 1, "{out:?}");
assert_eq!(out["browser_uuid"], "abc");
assert!(
!out.contains_key("session"),
"an unlisted cookie must never be copied"
);
}
fn fmt(spec: serde_json::Value) -> Result<String, String> {
format_set_cookie(&spec)
}
#[test]
fn a_full_cookie_renders_its_attributes_in_rfc_order() {
let out = fmt(serde_json::json!({
"name": "session", "value": "abc.def",
"path": "/", "domain": "example.com",
"max_age": 2592000, "same_site": "Lax",
"http_only": true, "secure": true
}))
.expect("valid cookie");
assert_eq!(
out,
"session=abc.def; Path=/; Domain=example.com; Max-Age=2592000; \
SameSite=Lax; HttpOnly; Secure"
);
}
#[test]
fn an_empty_value_is_allowed_because_that_is_how_a_cookie_is_cleared() {
assert_eq!(
fmt(serde_json::json!({"name": "oauth_state", "value": "", "path": "/", "max_age": 0}))
.expect("valid cookie"),
"oauth_state=; Path=/; Max-Age=0"
);
}
#[test]
fn a_false_flag_emits_nothing() {
assert_eq!(
fmt(
serde_json::json!({"name": "a", "value": "1", "http_only": false, "secure": false})
)
.expect("valid cookie"),
"a=1"
);
}
#[test]
fn injection_shaped_values_are_refused() {
for bad in [
serde_json::json!({"name": "a", "value": "x; Path=/; HttpOnly"}),
serde_json::json!({"name": "a", "value": "x\r\nSet-Cookie: b=2"}),
serde_json::json!({"name": "a", "value": "x,y"}),
serde_json::json!({"name": "a b", "value": "x"}),
serde_json::json!({"name": "a=b", "value": "x"}),
serde_json::json!({"name": "", "value": "x"}),
serde_json::json!({"name": "a", "value": "x", "path": "/; Domain=evil.test"}),
] {
assert!(
fmt(bad.clone()).is_err(),
"must be refused, rendered instead: {bad}"
);
}
}
#[test]
fn same_site_is_canonicalised_and_otherwise_refused() {
assert!(
fmt(serde_json::json!({"name": "a", "value": "1", "same_site": "lax"}))
.expect("valid")
.ends_with("SameSite=Lax")
);
assert!(
fmt(serde_json::json!({"name": "a", "value": "1", "same_site": "sometimes"})).is_err()
);
}
#[test]
fn a_jwt_value_survives_unchanged() {
let jwt = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.abc-_123";
let out = fmt(serde_json::json!({"name": "session", "value": jwt})).expect("valid");
assert_eq!(out, format!("session={jwt}"));
}
#[test]
fn what_the_writer_emits_the_parser_reads_back() {
let out = fmt(serde_json::json!({
"name": "session", "value": "abc.def", "path": "/", "http_only": true
}))
.expect("valid");
let pair = out.split(';').next().expect("the name=value pair");
assert_eq!(one(pair, "session").as_deref(), Some("abc.def"));
}
}