///|
/// HTTP Cookie Jar — RFC 6265 compliant cookie storage
///
/// SECURITY: Cookie support is DISABLED by default.
/// Enable via CookieJar::new(enabled=true) only for testing or when explicitly needed.
///|
/// Parsed Set-Cookie attributes
pub(all) struct ParsedCookie {
name : String
value : String
domain : String
path : String
secure : Bool
http_only : Bool
same_site : String // "strict" | "lax" | "none" | ""
max_age : Int? // seconds
expires : String? // raw date string
} derive(Debug)
///|
/// Cookie Jar — stores cookies per-domain with security enforcement
pub struct CookieJar {
cookies : Array[ParsedCookie]
/// SECURITY: When false (default), all operations are no-ops
enabled : Bool
}
///|
pub fn CookieJar::new(enabled? : Bool = false) -> CookieJar {
{ cookies: [], enabled }
}
///|
pub impl Show for ParsedCookie with fn output(self, logger) {
logger.write_string("{name: ")
self.name.output(logger)
logger.write_string(", value: ")
self.value.output(logger)
logger.write_string(", domain: ")
self.domain.output(logger)
logger.write_string(", path: ")
self.path.output(logger)
logger.write_string(", secure: ")
self.secure.output(logger)
logger.write_string(", http_only: ")
self.http_only.output(logger)
logger.write_string(", same_site: ")
self.same_site.output(logger)
logger.write_string(", max_age: ")
logger.write_object(to_repr(self.max_age))
logger.write_string(", expires: ")
logger.write_object(to_repr(self.expires))
logger.write_string("}")
}
///|
/// Parse a Set-Cookie header value into a ParsedCookie.
/// Returns None if the cookie is malformed.
pub fn parse_set_cookie(
header : String,
request_domain : String,
request_path : String,
) -> ParsedCookie? {
if header.is_empty() {
return None
}
// Split name=value from attributes
let parts = header.split(";")
let mut first = true
let mut name = ""
let mut value = ""
let mut domain = ""
let mut path = ""
let mut secure = false
let mut http_only = false
let mut same_site = ""
let mut max_age : Int? = None
let mut expires : String? = None
let mut domain_set = false
let mut path_set = false
for part in parts {
let trimmed = part.trim(chars=" ").to_owned()
if first {
first = false
// name=value pair
match trimmed.find("=") {
Some(eq_idx) => {
name = trimmed[:eq_idx].trim().to_owned()
value = trimmed[eq_idx + 1:].trim().to_owned()
}
None =>
// Cookie with no value
name = trimmed
}
// Empty cookie name is invalid (RFC 6265 §5.2)
if name.is_empty() {
return None
}
continue
}
// Parse attributes
let lower = trimmed.to_lower()
if lower == "secure" {
secure = true
} else if lower == "httponly" {
http_only = true
} else if lower.has_prefix("domain=") {
let d = trimmed[7:].trim().to_owned().to_lower()
// Strip leading dot (RFC 6265 §5.2.3)
domain = if d.has_prefix(".") { d[1:].to_owned() } else { d }
domain_set = true
} else if lower.has_prefix("path=") {
path = trimmed[5:].trim().to_owned()
path_set = true
} else if lower.has_prefix("max-age=") {
let val = trimmed[8:].trim().to_owned()
max_age = parse_cookie_int(val)
} else if lower.has_prefix("expires=") {
expires = Some(trimmed[8:].trim().to_owned())
} else if lower.has_prefix("samesite=") {
same_site = trimmed[9:].trim().to_owned().to_lower()
}
}
// Default domain to request domain if not set
if !domain_set {
domain = request_domain.to_lower()
}
// Default path per RFC 6265 §5.1.4
if !path_set {
path = cookie_default_path(request_path)
}
// Domain validation: cookie domain must be a suffix of request domain
// or equal (RFC 6265 §5.3 step 6)
if domain_set {
let req_lower = request_domain.to_lower()
if req_lower != domain && !req_lower.has_suffix("." + domain) {
return None // Reject: domain does not match request
}
}
Some({
name,
value,
domain,
path,
secure,
http_only,
same_site,
max_age,
expires,
})
}
///|
/// Get the default cookie path from a request path (RFC 6265 §5.1.4)
fn cookie_default_path(request_path : String) -> String {
if request_path.is_empty() || !request_path.has_prefix("/") {
return "/"
}
// Find last '/' (excluding trailing)
let mut last_slash = 0
for i = 1; i < request_path.length(); i = i + 1 {
if request_path[i] == '/' {
last_slash = i
}
}
if last_slash == 0 {
"/"
} else {
request_path[:last_slash].to_owned()
}
}
///|
/// Parse an integer for cookie max-age (allows negative values)
fn parse_cookie_int(s : String) -> Int? {
if s.is_empty() {
return None
}
let mut result = 0
let mut negative = false
let mut start = 0
if s[0] == '-' {
negative = true
start = 1
}
if start >= s.length() {
return None
}
for i = start; i < s.length(); i = i + 1 {
let c = s[i]
if c >= '0' && c <= '9' {
result = result * 10 + (c.to_int() - '0'.to_int())
} else {
return None
}
}
if negative {
Some(-result)
} else {
Some(result)
}
}
///|
/// Store a cookie from a Set-Cookie header.
/// Enforces: domain validation, secure flag, httpOnly flag.
pub fn CookieJar::store_from_header(
self : CookieJar,
set_cookie_header : String,
request_url : String,
is_secure_origin : Bool,
) -> Unit {
if !self.enabled {
return
}
let req_domain = cookie_extract_domain(request_url)
let req_path = cookie_extract_path(request_url)
let cookie = match parse_set_cookie(set_cookie_header, req_domain, req_path) {
Some(c) => c
None => return
}
// Security: Secure cookies only from secure origins
if cookie.secure && !is_secure_origin {
return
}
// Security: __Secure- prefix requires Secure flag (RFC 6265bis §4.1.3)
if cookie.name.has_prefix("__Secure-") && !cookie.secure {
return
}
// Security: __Host- prefix requires Secure, no explicit Domain, Path=/ (RFC 6265bis §4.1.3)
if cookie.name.has_prefix("__Host-") {
if !cookie.secure || cookie.path != "/" || cookie.domain != req_domain {
// __Host- cookies must not have Domain attribute set (domain == request domain only)
return
}
}
// Security: SameSite=None requires Secure flag (RFC 6265bis)
if cookie.same_site == "none" && !cookie.secure {
return
}
// max-age=0 or negative: delete cookie
match cookie.max_age {
Some(age) if age <= 0 => {
self.remove_cookie(cookie.name, cookie.domain, cookie.path)
return
}
_ => ()
}
// Replace existing cookie with same name+domain+path
self.remove_cookie(cookie.name, cookie.domain, cookie.path)
self.cookies.push(cookie)
}
///|
/// Get cookies for a request URL as "Cookie: name=value; name2=value2" pairs.
/// Enforces: domain matching, path matching, secure flag, httpOnly, SameSite.
///
/// `site_origin`: the origin of the page making the request (for SameSite checks).
/// Empty string means "same-site" (no cross-site enforcement).
/// `is_top_level_navigation`: true for top-level navigations (SameSite=Lax allows these).
pub fn CookieJar::get_cookie_header(
self : CookieJar,
request_url : String,
is_secure : Bool,
is_http_api : Bool,
site_origin? : String = "",
is_top_level_navigation? : Bool = false,
) -> String? {
if !self.enabled {
return None
}
let req_domain = cookie_extract_domain(request_url)
let req_path = cookie_extract_path(request_url)
let cross_site = if site_origin.is_empty() {
false
} else {
let site_domain = cookie_extract_domain(site_origin)
!cookie_same_site(site_domain, req_domain)
}
let buf = StringBuilder::new()
let mut first = true
for cookie in self.cookies {
// Domain match (RFC 6265 §5.4)
if !cookie_domain_matches(req_domain, cookie.domain) {
continue
}
// Path match (RFC 6265 §5.4)
if !cookie_path_matches(req_path, cookie.path) {
continue
}
// Secure cookie only over secure channel
if cookie.secure && !is_secure {
continue
}
// httpOnly cookies only sent in HTTP APIs (not accessible to JS)
if cookie.http_only && !is_http_api {
continue
}
// SameSite enforcement (RFC 6265bis)
if cross_site {
let effective_same_site = if cookie.same_site.is_empty() {
"lax" // Default is Lax per modern browsers
} else {
cookie.same_site
}
match effective_same_site {
"strict" => continue // Never sent cross-site
"lax" =>
// Lax: only sent on top-level navigation (GET)
if !is_top_level_navigation {
continue
}
"none" =>
// SameSite=None requires Secure flag
if !cookie.secure {
continue
}
_ =>
// Unknown value → treat as Lax per WHATWG RFC 6265bis
if !is_top_level_navigation {
continue
}
}
}
if !first {
buf.write_string("; ")
}
first = false
buf.write_string(cookie.name)
buf.write_char('=')
buf.write_string(cookie.value)
}
let result = buf.to_string()
if result.is_empty() {
None
} else {
Some(result)
}
}
///|
/// Check if two domains are "same-site" (share the same registrable
/// domain). Backed by the curated Public Suffix List in `http/psl`, so
/// `example.co.uk` and `other.co.uk` correctly classify as different
/// sites instead of collapsing under the last-two-labels heuristic.
fn cookie_same_site(domain_a : String, domain_b : String) -> Bool {
let a = domain_a.to_lower()
let b = domain_b.to_lower()
if a == b {
return true
}
let reg_a = @psl.registrable_domain(a)
let reg_b = @psl.registrable_domain(b)
reg_a == reg_b && reg_a.length() > 0
}
///|
/// Remove a cookie by name+domain+path
fn CookieJar::remove_cookie(
self : CookieJar,
name : String,
domain : String,
path : String,
) -> Unit {
let mut i = 0
while i < self.cookies.length() {
let c = self.cookies[i]
if c.name == name && c.domain == domain && c.path == path {
self.cookies.remove(i) |> ignore
} else {
i = i + 1
}
}
}
///|
/// Clear all cookies
pub fn CookieJar::clear(self : CookieJar) -> Unit {
self.cookies.clear()
}
///|
/// Get number of stored cookies
pub fn CookieJar::size(self : CookieJar) -> Int {
self.cookies.length()
}
///|
/// Domain match per RFC 6265 §5.1.3
fn cookie_domain_matches(
request_domain : String,
cookie_domain : String,
) -> Bool {
let req = request_domain.to_lower()
let cd = cookie_domain.to_lower()
if req == cd {
return true
}
// request domain must end with ".cookiedomain"
req.has_suffix("." + cd)
}
///|
/// Path match per RFC 6265 §5.1.4
fn cookie_path_matches(request_path : String, cookie_path : String) -> Bool {
if request_path == cookie_path {
return true
}
if request_path.has_prefix(cookie_path) {
// Cookie path must end with / or request path must have / after cookie path
if cookie_path.has_suffix("/") {
return true
}
if request_path.length() > cookie_path.length() &&
request_path[cookie_path.length()] == '/' {
return true
}
}
false
}
///|
/// Extract domain from URL
fn cookie_extract_domain(url : String) -> String {
let host = match url.find("://") {
Some(i) => {
let after = url[i + 3:].to_owned()
let end = match after.find("/") {
Some(j) => j
None => after.length()
}
let host_port = after[:end].to_owned()
// Remove port
match host_port.find(":") {
Some(j) => host_port[:j].to_owned().to_lower()
None => host_port.to_lower()
}
}
None => url.to_lower()
}
// Strip a single trailing dot — FQDN forms like "example.com." are
// equivalent to "example.com" for site comparison.
if host.has_suffix(".") && host.length() > 1 {
host.unsafe_substring(start=0, end=host.length() - 1)
} else {
host
}
}
///|
/// Extract path from URL
fn cookie_extract_path(url : String) -> String {
match url.find("://") {
Some(i) => {
let after = url[i + 3:].to_owned()
match after.find("/") {
Some(j) => after[j:].to_owned()
None => "/"
}
}
None => "/"
}
}