diff --git a/crates/js/src/dom_bridge.rs b/crates/js/src/dom_bridge.rs index 891750e..7ac5236 100644 --- a/crates/js/src/dom_bridge.rs +++ b/crates/js/src/dom_bridge.rs @@ -1136,6 +1136,79 @@ pub fn resolve_dom_get( } } +// ── Document-level dynamic properties (e.g. document.cookie) ────── + +/// Check whether `gc_ref` is the document object (has nodeType === 9 and +/// nodeName === "#document"). +fn is_document_object(gc: &Gc, gc_ref: GcRef) -> bool { + if let Some(HeapObject::Object(data)) = gc.get(gc_ref) { + if let Some(prop) = data.properties.get("nodeType") { + if let Value::Number(n) = &prop.value { + if *n == 9.0 { + return true; + } + } + } + } + false +} + +/// Resolve a dynamic property on the `document` object itself (not a node wrapper). +/// +/// Currently handles `document.cookie`. +pub fn resolve_document_get( + gc: &Gc, + bridge: &Rc, + gc_ref: GcRef, + key: &str, +) -> Option { + if key != "cookie" { + return None; + } + if !is_document_object(gc, gc_ref) { + return None; + } + + let url = bridge.document_url.borrow(); + let cookie_str = match url.as_ref() { + Some(u) => bridge.cookie_jar.borrow_mut().document_cookie_get(u), + None => String::new(), + }; + Some(Value::String(cookie_str)) +} + +/// Handle a property set on the `document` object (e.g. `document.cookie = "..."`)`. +/// +/// Returns `true` if the property was intercepted. +pub fn handle_document_set( + bridge: &Rc, + gc_ref: GcRef, + key: &str, + val: &Value, + gc: &Gc, +) -> bool { + if key != "cookie" { + return false; + } + if !is_document_object(gc, gc_ref) { + return false; + } + + let cookie_str = match val { + Value::String(s) => s.clone(), + other => other.to_string(), + }; + + let url = bridge.document_url.borrow(); + if let Some(u) = url.as_ref() { + bridge + .cookie_jar + .borrow_mut() + .document_cookie_set(&cookie_str, u); + } + true +} + /// Handle a DOM property set on a wrapper object. /// Returns `true` if the key was handled (caller should skip normal property set). pub fn handle_dom_set( diff --git a/crates/js/src/vm.rs b/crates/js/src/vm.rs index 61a7431..dc24b24 100644 --- a/crates/js/src/vm.rs +++ b/crates/js/src/vm.rs @@ -240,6 +240,10 @@ pub struct DomBridge { /// The serialized origin of this document (e.g. "https://example.com"). /// Used for Same-Origin Policy enforcement on cross-origin DOM access. pub origin: RefCell, + /// Cookie jar shared with the network layer for `document.cookie` access. + pub cookie_jar: RefCell, + /// The URL of the current document, used for cookie domain/path matching. + pub document_url: RefCell>, } /// Context passed to native functions, providing GC access and `this` binding. @@ -844,6 +848,8 @@ impl Vm { node_wrappers: RefCell::new(HashMap::new()), event_listeners: RefCell::new(HashMap::new()), origin: RefCell::new(String::new()), + cookie_jar: RefCell::new(we_net::cookie::CookieJar::new()), + document_url: RefCell::new(None), }); self.dom_bridge = Some(bridge); crate::dom_bridge::init_document_object(self); @@ -861,6 +867,27 @@ impl Vm { } } + /// Set the document URL for cookie domain/path matching. + pub fn set_document_url(&mut self, url: we_url::Url) { + if let Some(bridge) = &self.dom_bridge { + *bridge.document_url.borrow_mut() = Some(url); + } + } + + /// Set the cookie jar on the DOM bridge (typically from the HTTP client). + pub fn set_cookie_jar(&mut self, jar: we_net::cookie::CookieJar) { + if let Some(bridge) = &self.dom_bridge { + *bridge.cookie_jar.borrow_mut() = jar; + } + } + + /// Take the cookie jar from the DOM bridge (to return to the HTTP client). + pub fn take_cookie_jar(&mut self) -> Option { + self.dom_bridge + .as_ref() + .map(|bridge| bridge.cookie_jar.replace(we_net::cookie::CookieJar::new())) + } + /// Detach the DOM document from the VM, returning it. /// /// This removes the `document` global and disconnects the DOM bridge. @@ -2096,14 +2123,23 @@ impl Vm { /// Returns `Some(value)` if the key is a recognized DOM property, `None` otherwise. fn resolve_dom_property(&mut self, gc_ref: GcRef, key: &str) -> Option { let bridge = Rc::clone(self.dom_bridge.as_ref()?); - crate::dom_bridge::resolve_dom_get(&mut self.gc, &bridge, gc_ref, key) + // Try node wrapper properties first. + if let Some(val) = crate::dom_bridge::resolve_dom_get(&mut self.gc, &bridge, gc_ref, key) { + return Some(val); + } + // Try document-level dynamic properties (e.g. document.cookie). + crate::dom_bridge::resolve_document_get(&self.gc, &bridge, gc_ref, key) } /// Handle a DOM property set on a wrapper object. /// Returns `true` if the property was handled (caller should skip normal set). fn handle_dom_property_set(&mut self, gc_ref: GcRef, key: &str, val: &Value) -> bool { if let Some(bridge) = self.dom_bridge.clone() { - // Check for style proxy objects first. + // Check for document-level dynamic properties (e.g. document.cookie). + if crate::dom_bridge::handle_document_set(&bridge, gc_ref, key, val, &self.gc) { + return true; + } + // Check for style proxy objects. if crate::dom_bridge::handle_style_set(&mut self.gc, &bridge, gc_ref, key, val) { return true; } diff --git a/crates/net/src/client.rs b/crates/net/src/client.rs index 11ecae9..d80b3fa 100644 --- a/crates/net/src/client.rs +++ b/crates/net/src/client.rs @@ -10,6 +10,7 @@ use std::time::{Duration, Instant}; use we_url::Url; +use crate::cookie::{CookieJar, RequestContext}; use crate::http::{self, Headers, HttpResponse, Method}; use crate::tcp::{self, TcpConnection}; use crate::tls::handshake::{self, HandshakeError, TlsStream}; @@ -200,12 +201,13 @@ impl ConnectionPool { // HttpClient // --------------------------------------------------------------------------- -/// High-level HTTP/1.1 client with connection pooling and redirect following. +/// High-level HTTP/1.1 client with connection pooling, redirect following, and cookie jar. pub struct HttpClient { pool: ConnectionPool, max_redirects: u32, connect_timeout: Duration, read_timeout: Duration, + cookie_jar: CookieJar, } impl HttpClient { @@ -216,9 +218,20 @@ impl HttpClient { max_redirects: DEFAULT_MAX_REDIRECTS, connect_timeout: DEFAULT_CONNECT_TIMEOUT, read_timeout: DEFAULT_READ_TIMEOUT, + cookie_jar: CookieJar::new(), } } + /// Get a reference to the cookie jar. + pub fn cookie_jar(&self) -> &CookieJar { + &self.cookie_jar + } + + /// Get a mutable reference to the cookie jar. + pub fn cookie_jar_mut(&mut self) -> &mut CookieJar { + &mut self.cookie_jar + } + /// Set the maximum number of redirects to follow. pub fn set_max_redirects(&mut self, max: u32) { self.max_redirects = max; @@ -311,6 +324,20 @@ impl HttpClient { let path = request_path(url); + // Build request headers, attaching cookies from the jar. + let mut merged_headers = Headers::new(); + for (name, value) in headers.iter() { + merged_headers.add(name, value); + } + if !merged_headers.contains("Cookie") { + if let Some(cookie_val) = self + .cookie_jar + .cookie_header_value(url, RequestContext::SameSite) + { + merged_headers.add("Cookie", &cookie_val); + } + } + let key = ConnectionKey { host: host.clone(), port, @@ -326,13 +353,24 @@ impl HttpClient { conn.set_read_timeout(Some(self.read_timeout))?; // Serialize and send request - let request_bytes = http::serialize_request(method, &path, &host, headers, body); + let request_bytes = http::serialize_request(method, &path, &host, &merged_headers, body); conn.write_all(&request_bytes)?; conn.flush()?; // Read and parse response let response = read_response(&mut conn)?; + // Store Set-Cookie headers from the response. + let set_cookies: Vec = response + .headers + .get_all("Set-Cookie") + .into_iter() + .map(|s| s.to_string()) + .collect(); + for header in &set_cookies { + self.cookie_jar.store_from_header(header, url); + } + // Return connection to pool if keep-alive if !response.connection_close() { self.pool.put(key, conn); diff --git a/crates/net/src/cookie.rs b/crates/net/src/cookie.rs new file mode 100644 index 0000000..93cc8e5 --- /dev/null +++ b/crates/net/src/cookie.rs @@ -0,0 +1,1239 @@ +//! Cookie jar: parsing, storage, and matching per RFC 6265bis. +//! +//! Implements `Set-Cookie` header parsing, domain/path matching, expiry +//! handling, and cookie attachment to outgoing requests. + +use std::collections::HashMap; +use std::time::{SystemTime, UNIX_EPOCH}; + +use we_url::Url; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +/// Maximum cookies per domain. +const MAX_COOKIES_PER_DOMAIN: usize = 50; +/// Maximum total cookies in the jar. +const MAX_TOTAL_COOKIES: usize = 3000; + +// --------------------------------------------------------------------------- +// SameSite +// --------------------------------------------------------------------------- + +/// The SameSite attribute for a cookie. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SameSite { + /// Cookie is only sent in first-party (same-site) context. + Strict, + /// Cookie is sent on top-level navigations but not subresource requests. + Lax, + /// Cookie is sent in all contexts (requires Secure). + None, +} + +// --------------------------------------------------------------------------- +// Cookie +// --------------------------------------------------------------------------- + +/// A single HTTP cookie. +#[derive(Debug, Clone)] +pub struct Cookie { + /// Cookie name. + pub name: String, + /// Cookie value. + pub value: String, + /// The domain the cookie applies to (lowercase, no leading dot stored). + pub domain: String, + /// The path the cookie applies to. + pub path: String, + /// Absolute expiry time in seconds since UNIX epoch, or `None` for session cookies. + pub expires: Option, + /// Whether the cookie should only be sent over HTTPS. + pub secure: bool, + /// Whether the cookie is inaccessible to JavaScript (`document.cookie`). + pub http_only: bool, + /// SameSite attribute. + pub same_site: SameSite, + /// Whether the domain attribute was explicitly set (host-only vs domain cookie). + pub host_only: bool, + /// Creation time in seconds since UNIX epoch. + pub creation_time: u64, +} + +// --------------------------------------------------------------------------- +// RequestContext — describes the type of request for SameSite +// --------------------------------------------------------------------------- + +/// Context for cookie matching — determines SameSite behavior. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RequestContext { + /// Top-level navigation (e.g. clicking a link, typing in the address bar). + Navigation, + /// A subresource request (e.g. img, script, fetch). + Subresource, + /// Same-site request (document and target share the same registrable domain). + SameSite, +} + +// --------------------------------------------------------------------------- +// Set-Cookie parsing +// --------------------------------------------------------------------------- + +/// Parse a `Set-Cookie` header value into a `Cookie`. +/// +/// `request_url` is used to derive default domain/path when not specified. +/// Returns `None` if the header is malformed (empty name, etc.). +pub fn parse_set_cookie(header: &str, request_url: &Url) -> Option { + // Split on ';' to get name=value and attributes. + let mut parts = header.split(';'); + let name_value = parts.next()?.trim(); + + // Parse name=value pair. + let (name, value) = if let Some(eq_pos) = name_value.find('=') { + let n = name_value[..eq_pos].trim(); + let v = name_value[eq_pos + 1..].trim(); + // Strip quotes from value if present. + let v = if v.len() >= 2 && v.starts_with('"') && v.ends_with('"') { + &v[1..v.len() - 1] + } else { + v + }; + (n, v) + } else { + // No '=' — treat entire thing as value with empty name per spec. + // But empty name is rejected below. + ("", name_value.trim()) + }; + + // Reject empty names. + if name.is_empty() { + return None; + } + + // Reject names/values with forbidden characters (control chars, semicolons in name). + if name.bytes().any(|b| b < 0x20 || b == 0x7f || b == b';') { + return None; + } + + let now = now_secs(); + let request_host = request_url + .host_str() + .unwrap_or_default() + .to_ascii_lowercase(); + let request_path = request_url.path(); + + let mut domain: Option = None; + let mut path: Option = None; + let mut expires: Option = None; + let mut max_age: Option = None; + let mut secure = false; + let mut http_only = false; + let mut same_site = SameSite::Lax; // Default per RFC 6265bis + + // Parse attributes. + for part in parts { + let part = part.trim(); + if part.is_empty() { + continue; + } + + if let Some(eq_pos) = part.find('=') { + let attr_name = part[..eq_pos].trim(); + let attr_value = part[eq_pos + 1..].trim(); + + if attr_name.eq_ignore_ascii_case("Domain") { + let mut d = attr_value.to_ascii_lowercase(); + // Strip leading dot per spec. + if d.starts_with('.') { + d = d[1..].to_string(); + } + if !d.is_empty() { + domain = Some(d); + } + } else if attr_name.eq_ignore_ascii_case("Path") { + if attr_value.starts_with('/') { + path = Some(attr_value.to_string()); + } + } else if attr_name.eq_ignore_ascii_case("Expires") { + if let Some(t) = parse_cookie_date(attr_value) { + expires = Some(t); + } + } else if attr_name.eq_ignore_ascii_case("Max-Age") { + if let Ok(secs) = attr_value.parse::() { + max_age = Some(secs); + } + } else if attr_name.eq_ignore_ascii_case("SameSite") { + if attr_value.eq_ignore_ascii_case("Strict") { + same_site = SameSite::Strict; + } else if attr_value.eq_ignore_ascii_case("Lax") { + same_site = SameSite::Lax; + } else if attr_value.eq_ignore_ascii_case("None") { + same_site = SameSite::None; + } + } + } else { + // Flag-only attributes. + if part.eq_ignore_ascii_case("Secure") { + secure = true; + } else if part.eq_ignore_ascii_case("HttpOnly") { + http_only = true; + } + } + } + + // SameSite=None requires Secure. + if same_site == SameSite::None && !secure { + same_site = SameSite::Lax; + } + + // Determine effective expiry: Max-Age takes precedence over Expires. + let effective_expires = if let Some(ma) = max_age { + if ma <= 0 { + Some(0) // Expire immediately. + } else { + Some(now.saturating_add(ma as u64)) + } + } else { + expires + }; + + // Determine domain: if not set, use request host (host-only cookie). + let host_only; + let effective_domain = if let Some(d) = domain { + // Domain must domain-match the request host. + if !domain_matches(&request_host, &d) { + return None; // Reject cookie with mismatched domain. + } + host_only = false; + d + } else { + host_only = true; + request_host + }; + + // Determine path: if not set, use the default path from the request URL. + let effective_path = path.unwrap_or_else(|| default_cookie_path(&request_path)); + + Some(Cookie { + name: name.to_string(), + value: value.to_string(), + domain: effective_domain, + path: effective_path, + expires: effective_expires, + secure, + http_only, + same_site, + host_only, + creation_time: now, + }) +} + +/// Compute the default cookie path from a request URI path per RFC 6265bis Section 5.1.4. +fn default_cookie_path(request_path: &str) -> String { + if !request_path.starts_with('/') { + return "/".to_string(); + } + match request_path.rfind('/') { + Some(pos) if pos > 0 => request_path[..pos].to_string(), + _ => "/".to_string(), + } +} + +// --------------------------------------------------------------------------- +// Domain / path matching +// --------------------------------------------------------------------------- + +/// Check if `request_host` domain-matches `cookie_domain` per RFC 6265bis. +/// +/// `domain_matches("sub.example.com", "example.com")` → true +/// `domain_matches("example.com", "example.com")` → true +/// `domain_matches("notexample.com", "example.com")` → false +pub fn domain_matches(request_host: &str, cookie_domain: &str) -> bool { + let rh = request_host.to_ascii_lowercase(); + let cd = cookie_domain.to_ascii_lowercase(); + + if rh == cd { + return true; + } + + // request_host must end with "."+cookie_domain + if rh.len() > cd.len() { + let suffix_start = rh.len() - cd.len(); + if rh[suffix_start..] == cd && rh.as_bytes()[suffix_start - 1] == b'.' { + // cookie_domain must not be an IP address + if cd.parse::().is_ok() { + return false; + } + return true; + } + } + + false +} + +/// Check if `request_path` path-matches `cookie_path` per RFC 6265bis. +/// +/// `/foo` matches `/foo`, `/foo/bar`, `/foo/` but NOT `/foobar`. +pub fn path_matches(request_path: &str, cookie_path: &str) -> bool { + if request_path == cookie_path { + return true; + } + + if request_path.starts_with(cookie_path) { + // cookie_path ends with '/' — any subpath matches. + if cookie_path.ends_with('/') { + return true; + } + // The next char in request_path after the cookie_path must be '/'. + if request_path.as_bytes().get(cookie_path.len()) == Some(&b'/') { + return true; + } + } + + false +} + +// --------------------------------------------------------------------------- +// Cookie date parsing (simplified) +// --------------------------------------------------------------------------- + +/// Parse a cookie date string (RFC 6265bis Section 5.1.1). +/// +/// Supports common formats: +/// - `Thu, 01 Dec 2025 00:00:00 GMT` +/// - `Thu, 01-Dec-2025 00:00:00 GMT` +/// - `01 Dec 2025 00:00:00` +fn parse_cookie_date(input: &str) -> Option { + // Tokenize: split on delimiters (any non-alphanumeric except ':'). + let tokens: Vec<&str> = input + .split(|c: char| !c.is_alphanumeric() && c != ':') + .filter(|t| !t.is_empty()) + .collect(); + + let mut hour: Option = None; + let mut minute: Option = None; + let mut second: Option = None; + let mut day: Option = None; + let mut month: Option = None; + let mut year: Option = None; + + for token in &tokens { + // Try time HH:MM:SS + if hour.is_none() && token.contains(':') { + let time_parts: Vec<&str> = token.split(':').collect(); + if time_parts.len() >= 3 { + if let (Ok(h), Ok(m), Ok(s)) = ( + time_parts[0].parse::(), + time_parts[1].parse::(), + time_parts[2].parse::(), + ) { + if h <= 23 && m <= 59 && s <= 59 { + hour = Some(h); + minute = Some(m); + second = Some(s); + continue; + } + } + } + } + + // Try month name + if month.is_none() { + if let Some(m) = parse_month(token) { + month = Some(m); + continue; + } + } + + // Try year (4 digits) or day (1-2 digits) + if let Ok(num) = token.parse::() { + if year.is_none() && (num >= 70 || token.len() >= 4) { + let y = if (0..=69).contains(&num) { + num + 2000 + } else if (70..=99).contains(&num) { + num + 1900 + } else { + num + }; + year = Some(y); + continue; + } + if day.is_none() && (1..=31).contains(&num) { + day = Some(num as u32); + continue; + } + if year.is_none() { + let y = if (0..=69).contains(&num) { + num + 2000 + } else if (70..=99).contains(&num) { + num + 1900 + } else { + num + }; + year = Some(y); + continue; + } + } + } + + let year = year?; + let month = month?; + let day = day?; + let hour = hour.unwrap_or(0); + let minute = minute.unwrap_or(0); + let second = second.unwrap_or(0); + + if year < 1601 || !(1..=31).contains(&day) { + return None; + } + + // Convert to seconds since UNIX epoch (simplified, no leap seconds). + date_to_epoch(year, month, day, hour, minute, second) +} + +fn parse_month(s: &str) -> Option { + let lower = s.to_ascii_lowercase(); + match lower.get(..3)? { + "jan" => Some(1), + "feb" => Some(2), + "mar" => Some(3), + "apr" => Some(4), + "may" => Some(5), + "jun" => Some(6), + "jul" => Some(7), + "aug" => Some(8), + "sep" => Some(9), + "oct" => Some(10), + "nov" => Some(11), + "dec" => Some(12), + _ => None, + } +} + +fn date_to_epoch(year: i64, month: u32, day: u32, hour: u32, min: u32, sec: u32) -> Option { + // Days in each month (non-leap year). + let days_in_month = [0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; + + if !(1..=12).contains(&month) { + return None; + } + + let is_leap = (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0); + let max_day = if month == 2 && is_leap { + 29 + } else { + days_in_month[month as usize] + }; + if day > max_day { + return None; + } + + // Days from epoch (1970-01-01) to the start of `year`. + let mut total_days: i64 = 0; + if year >= 1970 { + for y in 1970..year { + total_days += if (y % 4 == 0 && y % 100 != 0) || (y % 400 == 0) { + 366 + } else { + 365 + }; + } + } else { + for y in year..1970 { + total_days -= if (y % 4 == 0 && y % 100 != 0) || (y % 400 == 0) { + 366 + } else { + 365 + }; + } + } + + // Add days for months in current year. + for m in 1..month { + total_days += days_in_month[m as usize] as i64; + if m == 2 && is_leap { + total_days += 1; + } + } + total_days += (day - 1) as i64; + + let total_secs = total_days * 86400 + hour as i64 * 3600 + min as i64 * 60 + sec as i64; + if total_secs < 0 { + return None; + } + Some(total_secs as u64) +} + +// --------------------------------------------------------------------------- +// CookieJar +// --------------------------------------------------------------------------- + +/// A cookie jar that stores cookies and selects them for outgoing requests. +#[derive(Debug)] +pub struct CookieJar { + /// Cookies keyed by domain for fast lookup. + cookies: HashMap>, + /// Total cookie count across all domains. + total_count: usize, +} + +impl CookieJar { + /// Create an empty cookie jar. + pub fn new() -> Self { + Self { + cookies: HashMap::new(), + total_count: 0, + } + } + + /// Store a cookie from a `Set-Cookie` header. + /// + /// Parses the header, replaces any existing cookie with the same + /// (domain, path, name) tuple, and enforces per-domain and total limits. + pub fn store_from_header(&mut self, header: &str, request_url: &Url) { + if let Some(cookie) = parse_set_cookie(header, request_url) { + self.store(cookie); + } + } + + /// Store a cookie directly. + pub fn store(&mut self, cookie: Cookie) { + // If expiry is in the past (or zero), remove the cookie instead. + if let Some(exp) = cookie.expires { + if exp <= now_secs() { + self.remove(&cookie.domain, &cookie.path, &cookie.name); + return; + } + } + + let domain = cookie.domain.clone(); + + // Check if we're replacing an existing cookie. + let is_replace = self + .cookies + .get(&domain) + .map(|entries| { + entries + .iter() + .any(|c| c.name == cookie.name && c.path == cookie.path) + }) + .unwrap_or(false); + + if is_replace { + let entries = self.cookies.get_mut(&domain).unwrap(); + let pos = entries + .iter() + .position(|c| c.name == cookie.name && c.path == cookie.path) + .unwrap(); + entries[pos] = cookie; + } else { + // Enforce per-domain limit: evict oldest if at capacity. + if let Some(entries) = self.cookies.get_mut(&domain) { + if entries.len() >= MAX_COOKIES_PER_DOMAIN { + if let Some(oldest_pos) = entries + .iter() + .enumerate() + .min_by_key(|(_, c)| c.creation_time) + .map(|(i, _)| i) + { + entries.remove(oldest_pos); + self.total_count -= 1; + } + } + } + + // Enforce total limit. + if self.total_count >= MAX_TOTAL_COOKIES { + self.evict_oldest_global(); + } + + self.cookies.entry(domain).or_default().push(cookie); + self.total_count += 1; + } + } + + /// Remove a specific cookie by (domain, path, name). + pub fn remove(&mut self, domain: &str, path: &str, name: &str) { + if let Some(entries) = self.cookies.get_mut(domain) { + let before = entries.len(); + entries.retain(|c| !(c.name == name && c.path == path)); + let removed = before - entries.len(); + self.total_count -= removed; + if entries.is_empty() { + self.cookies.remove(domain); + } + } + } + + /// Select cookies matching the given request URL and context. + /// + /// Returns cookies sorted by path length (longest first), then by + /// creation time (earliest first) — per RFC 6265bis. + pub fn cookies_for_request(&mut self, url: &Url, context: RequestContext) -> Vec<&Cookie> { + let now = now_secs(); + let is_secure = url.scheme() == "https"; + let host = url.host_str().unwrap_or_default().to_ascii_lowercase(); + let path = url.path(); + let path = if path.is_empty() { "/" } else { &path }; + + // Remove expired cookies first. + self.remove_expired(now); + + let mut result = Vec::new(); + + for (domain, entries) in &self.cookies { + for cookie in entries { + // Domain matching. + if cookie.host_only { + if host != cookie.domain { + continue; + } + } else if !domain_matches(&host, domain) { + continue; + } + + // Path matching. + if !path_matches(path, &cookie.path) { + continue; + } + + // Secure flag. + if cookie.secure && !is_secure { + continue; + } + + // SameSite. + match cookie.same_site { + SameSite::Strict => { + if context != RequestContext::SameSite { + continue; + } + } + SameSite::Lax => { + if context == RequestContext::Subresource { + continue; + } + } + SameSite::None => { + // SameSite=None requires Secure, already enforced at parse time. + } + } + + result.push(cookie); + } + } + + // Sort: longest path first, then earliest creation time. + result.sort_by(|a, b| { + b.path + .len() + .cmp(&a.path.len()) + .then(a.creation_time.cmp(&b.creation_time)) + }); + + result + } + + /// Serialize matching cookies into a `Cookie` header value. + pub fn cookie_header_value(&mut self, url: &Url, context: RequestContext) -> Option { + let cookies = self.cookies_for_request(url, context); + if cookies.is_empty() { + return None; + } + let pairs: Vec = cookies + .iter() + .map(|c| format!("{}={}", c.name, c.value)) + .collect(); + Some(pairs.join("; ")) + } + + /// Get non-HttpOnly cookies for `document.cookie` (JS getter). + pub fn document_cookie_get(&mut self, url: &Url) -> String { + let cookies = self.cookies_for_request(url, RequestContext::SameSite); + let pairs: Vec = cookies + .iter() + .filter(|c| !c.http_only) + .map(|c| format!("{}={}", c.name, c.value)) + .collect(); + pairs.join("; ") + } + + /// Parse and store a cookie from `document.cookie` (JS setter). + /// + /// HttpOnly cookies cannot be set via JS. Returns true if the cookie was stored. + pub fn document_cookie_set(&mut self, cookie_str: &str, url: &Url) -> bool { + if let Some(cookie) = parse_set_cookie(cookie_str, url) { + // document.cookie cannot set HttpOnly cookies. + if cookie.http_only { + return false; + } + self.store(cookie); + true + } else { + false + } + } + + /// Remove all expired cookies. + fn remove_expired(&mut self, now: u64) { + let mut empty_domains = Vec::new(); + + for (domain, entries) in &mut self.cookies { + let before = entries.len(); + entries.retain(|c| match c.expires { + Some(exp) => exp > now, + None => true, // Session cookies don't expire by time. + }); + let removed = before - entries.len(); + self.total_count -= removed; + if entries.is_empty() { + empty_domains.push(domain.clone()); + } + } + + for domain in empty_domains { + self.cookies.remove(&domain); + } + } + + /// Evict the globally oldest cookie to make room. + fn evict_oldest_global(&mut self) { + let mut oldest: Option<(String, usize, u64)> = None; + + for (domain, entries) in &self.cookies { + for (i, cookie) in entries.iter().enumerate() { + match oldest { + None => oldest = Some((domain.clone(), i, cookie.creation_time)), + Some((_, _, t)) if cookie.creation_time < t => { + oldest = Some((domain.clone(), i, cookie.creation_time)); + } + _ => {} + } + } + } + + if let Some((domain, idx, _)) = oldest { + if let Some(entries) = self.cookies.get_mut(&domain) { + entries.remove(idx); + self.total_count -= 1; + if entries.is_empty() { + self.cookies.remove(&domain); + } + } + } + } + + /// Process `Set-Cookie` headers from an HTTP response and store cookies. + pub fn store_from_response_headers(&mut self, set_cookie_headers: &[&str], request_url: &Url) { + for header in set_cookie_headers { + self.store_from_header(header, request_url); + } + } +} + +impl Default for CookieJar { + fn default() -> Self { + Self::new() + } +} + +// --------------------------------------------------------------------------- +// Time helpers +// --------------------------------------------------------------------------- + +fn now_secs() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + fn test_url(s: &str) -> Url { + Url::parse(s).unwrap() + } + + // -- parse_set_cookie tests -- + + #[test] + fn parse_basic_cookie() { + let url = test_url("https://example.com/path"); + let cookie = parse_set_cookie("name=value", &url).unwrap(); + assert_eq!(cookie.name, "name"); + assert_eq!(cookie.value, "value"); + assert_eq!(cookie.domain, "example.com"); + assert_eq!(cookie.path, "/"); + assert!(cookie.host_only); + assert!(!cookie.secure); + assert!(!cookie.http_only); + assert_eq!(cookie.same_site, SameSite::Lax); + assert!(cookie.expires.is_none()); + } + + #[test] + fn parse_cookie_with_all_attributes() { + let url = test_url("https://example.com/foo/bar"); + let header = "id=abc123; Domain=example.com; Path=/foo; Secure; HttpOnly; SameSite=Strict; Max-Age=3600"; + let cookie = parse_set_cookie(header, &url).unwrap(); + assert_eq!(cookie.name, "id"); + assert_eq!(cookie.value, "abc123"); + assert_eq!(cookie.domain, "example.com"); + assert_eq!(cookie.path, "/foo"); + assert!(cookie.secure); + assert!(cookie.http_only); + assert_eq!(cookie.same_site, SameSite::Strict); + assert!(!cookie.host_only); + assert!(cookie.expires.is_some()); + } + + #[test] + fn parse_cookie_empty_name_rejected() { + let url = test_url("https://example.com/"); + assert!(parse_set_cookie("=value", &url).is_none()); + } + + #[test] + fn parse_cookie_domain_mismatch_rejected() { + let url = test_url("https://example.com/"); + assert!(parse_set_cookie("name=val; Domain=evil.com", &url).is_none()); + } + + #[test] + fn parse_cookie_subdomain_domain() { + let url = test_url("https://sub.example.com/"); + let cookie = parse_set_cookie("name=val; Domain=example.com", &url).unwrap(); + assert_eq!(cookie.domain, "example.com"); + assert!(!cookie.host_only); + } + + #[test] + fn parse_cookie_leading_dot_stripped() { + let url = test_url("https://sub.example.com/"); + let cookie = parse_set_cookie("name=val; Domain=.example.com", &url).unwrap(); + assert_eq!(cookie.domain, "example.com"); + } + + #[test] + fn parse_cookie_max_age_zero_means_delete() { + let url = test_url("https://example.com/"); + let cookie = parse_set_cookie("name=val; Max-Age=0", &url).unwrap(); + assert_eq!(cookie.expires, Some(0)); + } + + #[test] + fn parse_cookie_max_age_overrides_expires() { + let url = test_url("https://example.com/"); + let header = "name=val; Expires=Thu, 01 Dec 2050 00:00:00 GMT; Max-Age=60"; + let cookie = parse_set_cookie(header, &url).unwrap(); + // Max-Age should produce an expiry close to now+60, not 2050. + let now = now_secs(); + let exp = cookie.expires.unwrap(); + assert!(exp >= now && exp <= now + 120); + } + + #[test] + fn parse_cookie_samesite_none_without_secure() { + let url = test_url("https://example.com/"); + let cookie = parse_set_cookie("name=val; SameSite=None", &url).unwrap(); + // SameSite=None without Secure should fall back to Lax. + assert_eq!(cookie.same_site, SameSite::Lax); + } + + #[test] + fn parse_cookie_quoted_value() { + let url = test_url("https://example.com/"); + let cookie = parse_set_cookie("name=\"hello world\"", &url).unwrap(); + assert_eq!(cookie.value, "hello world"); + } + + #[test] + fn parse_cookie_default_path() { + let url = test_url("https://example.com/a/b/c"); + let cookie = parse_set_cookie("name=val", &url).unwrap(); + assert_eq!(cookie.path, "/a/b"); + } + + #[test] + fn parse_cookie_default_path_root() { + let url = test_url("https://example.com/"); + let cookie = parse_set_cookie("name=val", &url).unwrap(); + assert_eq!(cookie.path, "/"); + } + + #[test] + fn parse_cookie_case_insensitive_attributes() { + let url = test_url("https://example.com/"); + let cookie = parse_set_cookie("name=val; SECURE; HTTPONLY; SAMESITE=STRICT", &url).unwrap(); + assert!(cookie.secure); + assert!(cookie.http_only); + assert_eq!(cookie.same_site, SameSite::Strict); + } + + // -- domain_matches tests -- + + #[test] + fn domain_matches_exact() { + assert!(domain_matches("example.com", "example.com")); + } + + #[test] + fn domain_matches_subdomain() { + assert!(domain_matches("sub.example.com", "example.com")); + } + + #[test] + fn domain_matches_deep_subdomain() { + assert!(domain_matches("a.b.c.example.com", "example.com")); + } + + #[test] + fn domain_no_match_prefix() { + assert!(!domain_matches("notexample.com", "example.com")); + } + + #[test] + fn domain_no_match_different() { + assert!(!domain_matches("evil.com", "example.com")); + } + + #[test] + fn domain_no_match_ip() { + assert!(!domain_matches("1.192.168.1.1", "192.168.1.1")); + } + + // -- path_matches tests -- + + #[test] + fn path_matches_exact() { + assert!(path_matches("/foo", "/foo")); + } + + #[test] + fn path_matches_subpath() { + assert!(path_matches("/foo/bar", "/foo")); + } + + #[test] + fn path_matches_with_trailing_slash() { + assert!(path_matches("/foo/bar", "/foo/")); + } + + #[test] + fn path_no_match_prefix() { + assert!(!path_matches("/foobar", "/foo")); + } + + #[test] + fn path_matches_root() { + assert!(path_matches("/anything", "/")); + } + + // -- CookieJar tests -- + + #[test] + fn jar_store_and_retrieve() { + let mut jar = CookieJar::new(); + let url = test_url("https://example.com/path"); + jar.store_from_header("session=abc", &url); + + let cookies = jar.cookies_for_request(&url, RequestContext::SameSite); + assert_eq!(cookies.len(), 1); + assert_eq!(cookies[0].name, "session"); + assert_eq!(cookies[0].value, "abc"); + } + + #[test] + fn jar_replace_existing_cookie() { + let mut jar = CookieJar::new(); + let url = test_url("https://example.com/"); + jar.store_from_header("name=old", &url); + jar.store_from_header("name=new", &url); + + let cookies = jar.cookies_for_request(&url, RequestContext::SameSite); + assert_eq!(cookies.len(), 1); + assert_eq!(cookies[0].value, "new"); + } + + #[test] + fn jar_expired_cookies_not_returned() { + let mut jar = CookieJar::new(); + let url = test_url("https://example.com/"); + jar.store_from_header("name=val; Max-Age=0", &url); + + let cookies = jar.cookies_for_request(&url, RequestContext::SameSite); + assert_eq!(cookies.len(), 0); + } + + #[test] + fn jar_secure_cookie_not_on_http() { + let mut jar = CookieJar::new(); + let https_url = test_url("https://example.com/"); + jar.store_from_header("name=val; Secure", &https_url); + + let http_url = test_url("http://example.com/"); + let cookies = jar.cookies_for_request(&http_url, RequestContext::SameSite); + assert_eq!(cookies.len(), 0); + } + + #[test] + fn jar_secure_cookie_on_https() { + let mut jar = CookieJar::new(); + let url = test_url("https://example.com/"); + jar.store_from_header("name=val; Secure", &url); + + let cookies = jar.cookies_for_request(&url, RequestContext::SameSite); + assert_eq!(cookies.len(), 1); + } + + #[test] + fn jar_host_only_cookie() { + let mut jar = CookieJar::new(); + let url = test_url("https://example.com/"); + jar.store_from_header("name=val", &url); + + // Should NOT match subdomain. + let sub_url = test_url("https://sub.example.com/"); + let cookies = jar.cookies_for_request(&sub_url, RequestContext::SameSite); + assert_eq!(cookies.len(), 0); + } + + #[test] + fn jar_domain_cookie_matches_subdomain() { + let mut jar = CookieJar::new(); + let url = test_url("https://example.com/"); + jar.store_from_header("name=val; Domain=example.com", &url); + + let sub_url = test_url("https://sub.example.com/"); + let cookies = jar.cookies_for_request(&sub_url, RequestContext::SameSite); + assert_eq!(cookies.len(), 1); + } + + #[test] + fn jar_path_matching() { + let mut jar = CookieJar::new(); + let url = test_url("https://example.com/foo/bar"); + jar.store_from_header("name=val; Path=/foo", &url); + + let match_url = test_url("https://example.com/foo/baz"); + let no_match_url = test_url("https://example.com/bar"); + + assert_eq!( + jar.cookies_for_request(&match_url, RequestContext::SameSite) + .len(), + 1 + ); + assert_eq!( + jar.cookies_for_request(&no_match_url, RequestContext::SameSite) + .len(), + 0 + ); + } + + #[test] + fn jar_cookie_header_value() { + let mut jar = CookieJar::new(); + let url = test_url("https://example.com/foo"); + jar.store_from_header("a=1; Path=/", &url); + jar.store_from_header("b=2; Path=/foo", &url); + + let header = jar + .cookie_header_value(&url, RequestContext::SameSite) + .unwrap(); + // /foo is longer than /, so b=2 comes first. + assert!(header.starts_with("b=2")); + assert!(header.contains("a=1")); + } + + #[test] + fn jar_samesite_strict_blocks_cross_site() { + let mut jar = CookieJar::new(); + let url = test_url("https://example.com/"); + jar.store_from_header("name=val; SameSite=Strict", &url); + + // Strict should not be sent on navigation from another site. + let cookies = jar.cookies_for_request(&url, RequestContext::Navigation); + assert_eq!(cookies.len(), 0); + + // But should be sent on same-site. + let cookies = jar.cookies_for_request(&url, RequestContext::SameSite); + assert_eq!(cookies.len(), 1); + } + + #[test] + fn jar_samesite_lax_allows_navigation() { + let mut jar = CookieJar::new(); + let url = test_url("https://example.com/"); + jar.store_from_header("name=val; SameSite=Lax", &url); + + // Lax sends on navigation. + let cookies = jar.cookies_for_request(&url, RequestContext::Navigation); + assert_eq!(cookies.len(), 1); + + // But NOT on subresource. + let cookies = jar.cookies_for_request(&url, RequestContext::Subresource); + assert_eq!(cookies.len(), 0); + } + + #[test] + fn jar_samesite_none_requires_secure_and_sends_everywhere() { + let mut jar = CookieJar::new(); + let url = test_url("https://example.com/"); + jar.store_from_header("name=val; SameSite=None; Secure", &url); + + let cookies = jar.cookies_for_request(&url, RequestContext::Subresource); + assert_eq!(cookies.len(), 1); + } + + #[test] + fn jar_httponly_hidden_from_document_cookie() { + let mut jar = CookieJar::new(); + let url = test_url("https://example.com/"); + jar.store_from_header("secret=hidden; HttpOnly", &url); + jar.store_from_header("visible=yes", &url); + + let doc_cookies = jar.document_cookie_get(&url); + assert!(!doc_cookies.contains("secret")); + assert!(doc_cookies.contains("visible=yes")); + } + + #[test] + fn jar_document_cookie_set() { + let mut jar = CookieJar::new(); + let url = test_url("https://example.com/"); + assert!(jar.document_cookie_set("name=value", &url)); + + let cookies = jar.cookies_for_request(&url, RequestContext::SameSite); + assert_eq!(cookies.len(), 1); + } + + #[test] + fn jar_document_cookie_set_httponly_rejected() { + let mut jar = CookieJar::new(); + let url = test_url("https://example.com/"); + assert!(!jar.document_cookie_set("name=val; HttpOnly", &url)); + } + + #[test] + fn jar_per_domain_limit() { + let mut jar = CookieJar::new(); + let url = test_url("https://example.com/"); + + // Store MAX_COOKIES_PER_DOMAIN + 1 cookies. + for i in 0..=MAX_COOKIES_PER_DOMAIN { + jar.store_from_header(&format!("cookie{i}=val{i}"), &url); + } + + let cookies = jar.cookies_for_request(&url, RequestContext::SameSite); + assert_eq!(cookies.len(), MAX_COOKIES_PER_DOMAIN); + } + + #[test] + fn jar_remove_cookie() { + let mut jar = CookieJar::new(); + let url = test_url("https://example.com/"); + jar.store_from_header("name=val", &url); + jar.remove("example.com", "/", "name"); + + let cookies = jar.cookies_for_request(&url, RequestContext::SameSite); + assert_eq!(cookies.len(), 0); + } + + #[test] + fn jar_multiple_domains() { + let mut jar = CookieJar::new(); + let url1 = test_url("https://a.com/"); + let url2 = test_url("https://b.com/"); + jar.store_from_header("name=a", &url1); + jar.store_from_header("name=b", &url2); + + let cookies1 = jar.cookies_for_request(&url1, RequestContext::SameSite); + assert_eq!(cookies1.len(), 1); + assert_eq!(cookies1[0].value, "a"); + + let cookies2 = jar.cookies_for_request(&url2, RequestContext::SameSite); + assert_eq!(cookies2.len(), 1); + assert_eq!(cookies2[0].value, "b"); + } + + // -- Cookie date parsing tests -- + + #[test] + fn parse_cookie_date_rfc1123() { + let t = parse_cookie_date("Thu, 01 Dec 2025 00:00:00 GMT").unwrap(); + // 2025-12-01 00:00:00 UTC + assert!(t > 0); + } + + #[test] + fn parse_cookie_date_rfc850() { + let t = parse_cookie_date("Thursday, 01-Dec-25 00:00:00 GMT").unwrap(); + assert!(t > 0); + } + + #[test] + fn parse_cookie_date_asctime() { + let t = parse_cookie_date("Dec 1 00:00:00 2025").unwrap(); + assert!(t > 0); + } + + // -- default_cookie_path tests -- + + #[test] + fn default_path_with_subpath() { + assert_eq!(default_cookie_path("/a/b/c"), "/a/b"); + } + + #[test] + fn default_path_root() { + assert_eq!(default_cookie_path("/"), "/"); + } + + #[test] + fn default_path_empty() { + assert_eq!(default_cookie_path(""), "/"); + } + + #[test] + fn default_path_single_segment() { + assert_eq!(default_cookie_path("/foo"), "/"); + } + + // -- date_to_epoch tests -- + + #[test] + fn epoch_1970() { + assert_eq!(date_to_epoch(1970, 1, 1, 0, 0, 0), Some(0)); + } + + #[test] + fn epoch_2000() { + // 2000-01-01 00:00:00 = 946684800 + assert_eq!(date_to_epoch(2000, 1, 1, 0, 0, 0), Some(946684800)); + } + + #[test] + fn epoch_invalid_month() { + assert_eq!(date_to_epoch(2020, 13, 1, 0, 0, 0), None); + } + + #[test] + fn epoch_invalid_day() { + assert_eq!(date_to_epoch(2020, 2, 30, 0, 0, 0), None); + } + + #[test] + fn epoch_leap_year() { + // 2020 is a leap year, Feb 29 should be valid. + assert!(date_to_epoch(2020, 2, 29, 0, 0, 0).is_some()); + // 2021 is not, Feb 29 should be invalid. + assert!(date_to_epoch(2021, 2, 29, 0, 0, 0).is_none()); + } +} diff --git a/crates/net/src/lib.rs b/crates/net/src/lib.rs index 1684fef..776cc01 100644 --- a/crates/net/src/lib.rs +++ b/crates/net/src/lib.rs @@ -1,6 +1,7 @@ //! TCP, DNS, pure-Rust TLS 1.3, HTTP/1.1, HTTP/2, CORS. pub mod client; +pub mod cookie; pub mod cors; pub mod dns; pub mod http;