Skip to main content

ferrijs_fetch/
cookie.rs

1//! RFC 6265 cookie parsing and matching for the bridged request path.
2//! Ported from Playwright's `server/cookieStore.ts` + `server/fetch.ts`
3//! cookie handling: the host's context is the jar, so the outgoing
4//! `Cookie` header is assembled here from its cookies and every hop's
5//! `Set-Cookie` is parsed back with the same defaults Playwright applies.
6
7/// One cookie as a host jar stores it. The field set is Playwright's
8/// `Cookie` / `SetNetworkCookieParam`, which is what a browser-backed
9/// jar speaks natively and a superset of what any other jar needs.
10#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
11#[serde(rename_all = "camelCase")]
12pub struct Cookie {
13  pub name: String,
14  pub value: String,
15  pub domain: String,
16  pub path: String,
17  pub secure: bool,
18  pub http_only: bool,
19  /// Unix time in seconds; `-1` or `None` for a session cookie.
20  pub expires: Option<f64>,
21  #[serde(default, skip_serializing_if = "Option::is_none")]
22  pub same_site: Option<SameSite>,
23  /// A URL to derive `domain` / `path` from when the jar supports it
24  /// (Playwright's `SetNetworkCookieParam.url`). Never populated on a
25  /// cookie read back from a jar.
26  #[serde(default, skip_serializing_if = "Option::is_none")]
27  pub url: Option<String>,
28}
29
30/// Cookie `SameSite` attribute (`Strict | Lax | None`).
31#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
32pub enum SameSite {
33  Strict,
34  Lax,
35  None,
36}
37
38impl SameSite {
39  /// The attribute value as a `Set-Cookie` header spells it.
40  #[must_use]
41  pub fn as_str(self) -> &'static str {
42    match self {
43      Self::Strict => "Strict",
44      Self::Lax => "Lax",
45      Self::None => "None",
46    }
47  }
48}
49
50impl std::str::FromStr for SameSite {
51  type Err = ();
52
53  fn from_str(s: &str) -> Result<Self, Self::Err> {
54    match s {
55      "Strict" => Ok(Self::Strict),
56      "Lax" => Ok(Self::Lax),
57      "None" => Ok(Self::None),
58      _ => Err(()),
59    }
60  }
61}
62
63/// RFC 6265 upper bound Playwright clamps cookie expiry to
64/// (`server/network.ts::kMaxCookieExpiresDateInSeconds`).
65const MAX_COOKIE_EXPIRES_SECONDS: f64 = 253_402_300_799.0;
66
67/// Secure-cookie carve-out: Playwright sends `Secure` cookies over
68/// plain http to localhost names (`server/network.ts::isLocalHostname`).
69fn is_local_hostname(hostname: &str) -> bool {
70  hostname == "localhost" || hostname.ends_with(".localhost")
71}
72
73/// RFC 6265 §5.1.3 domain-match as Playwright implements it
74/// (`server/cookieStore.ts::domainMatches`): exact host, or a
75/// dot-prefixed cookie domain suffix-matching the host.
76fn cookie_domain_matches(hostname: &str, domain: &str) -> bool {
77  if hostname == domain {
78    return true;
79  }
80  if !domain.starts_with('.') {
81    return false;
82  }
83  format!(".{hostname}").ends_with(domain)
84}
85
86/// RFC 6265 §5.1.4 path-match (`server/cookieStore.ts::pathMatches`).
87fn cookie_path_matches(request_path: &str, cookie_path: &str) -> bool {
88  if request_path == cookie_path {
89    return true;
90  }
91  let mut value = request_path.to_string();
92  if !value.ends_with('/') {
93    value.push('/');
94  }
95  let mut path = cookie_path.to_string();
96  if !path.ends_with('/') {
97    path.push('/');
98  }
99  value.starts_with(&path)
100}
101
102/// Whether a context cookie is sent on a request to `url`
103/// (`server/cookieStore.ts::Cookie.matches`, plus the expiry prune).
104/// `expires`: `None` / negative = session cookie (never expires here);
105/// `>= 0` = absolute epoch seconds, pruned when in the past.
106#[must_use]
107pub fn cookie_matches_url(cookie: &Cookie, url: &reqwest::Url) -> bool {
108  let hostname = url.host_str().unwrap_or("");
109  if cookie.secure && url.scheme() != "https" && !is_local_hostname(hostname) {
110    return false;
111  }
112  if !cookie_domain_matches(hostname, &cookie.domain) {
113    return false;
114  }
115  if !cookie_path_matches(url.path(), &cookie.path) {
116    return false;
117  }
118  if let Some(expires) = cookie.expires
119    && expires >= 0.0
120    && expires
121      < std::time::SystemTime::now()
122        .duration_since(std::time::UNIX_EPOCH)
123        .map_or(0.0, |d| d.as_secs_f64())
124  {
125    return false;
126  }
127  true
128}
129
130/// Parse one `Set-Cookie` header value into a [`Cookie`], porting
131/// Playwright's `parseRawCookie` (`server/cookieStore.ts:131`) +
132/// `parseCookie` defaults (`server/fetch.ts:889`). Returns `None` for an
133/// empty header.
134fn parse_raw_set_cookie(header: &str) -> Option<Cookie> {
135  let mut pairs = header.split(';').filter(|s| !s.trim().is_empty()).map(|p| {
136    p.split_once('=').map_or_else(
137      || (p.trim().to_string(), String::new()),
138      |(k, v)| (k.trim().to_string(), v.trim().to_string()),
139    )
140  });
141  let (name, value) = pairs.next()?;
142  let mut cookie = Cookie {
143    name,
144    value,
145    domain: String::new(),
146    path: String::new(),
147    secure: false,
148    http_only: false,
149    expires: None,
150    // Unspecified SameSite behaves as Lax (fetch.ts:900 comment); Playwright
151    // stores the default explicitly.
152    same_site: Some(SameSite::Lax),
153    url: None,
154  };
155  for (attr, attr_value) in pairs {
156    match attr.to_ascii_lowercase().as_str() {
157      "expires" => {
158        // RFC 6265 §5.2.1: unparsable dates are ignored; past dates clamp
159        // to the earliest representable time.
160        if let Ok(when) = httpdate::parse_http_date(&attr_value) {
161          let secs = when
162            .duration_since(std::time::UNIX_EPOCH)
163            .map_or(0.0, |d| d.as_secs_f64());
164          cookie.expires = Some(secs.min(MAX_COOKIE_EXPIRES_SECONDS));
165        }
166      },
167      "max-age" => {
168        // RFC 6265 §5.2.2: non-positive delta = earliest representable time.
169        if let Ok(delta) = attr_value.parse::<i64>() {
170          if delta <= 0 {
171            cookie.expires = Some(0.0);
172          } else {
173            let now = std::time::SystemTime::now()
174              .duration_since(std::time::UNIX_EPOCH)
175              .map_or(0.0, |d| d.as_secs_f64());
176            // u32 seconds (~136 years) is beyond the RFC clamp anyway;
177            // saturating keeps the arithmetic lossless for clippy.
178            let delta = f64::from(u32::try_from(delta).unwrap_or(u32::MAX));
179            cookie.expires = Some((now + delta).min(MAX_COOKIE_EXPIRES_SECONDS));
180          }
181        }
182      },
183      "domain" => {
184        let mut domain = attr_value.to_ascii_lowercase();
185        // Playwright normalises a dotted-but-not-dot-prefixed domain to its
186        // dot-prefixed (subdomain-matching) form.
187        if !domain.is_empty() && !domain.starts_with('.') && domain.contains('.') {
188          domain.insert(0, '.');
189        }
190        cookie.domain = domain;
191      },
192      "path" => cookie.path = attr_value,
193      "secure" => cookie.secure = true,
194      "httponly" => cookie.http_only = true,
195      "samesite" => {
196        cookie.same_site = match attr_value.to_ascii_lowercase().as_str() {
197          "none" => Some(SameSite::None),
198          "strict" => Some(SameSite::Strict),
199          "lax" => Some(SameSite::Lax),
200          _ => cookie.same_site,
201        };
202      },
203      _ => {},
204    }
205  }
206  Some(cookie)
207}
208
209/// Parse every `Set-Cookie` header on a response into browser-ready
210/// cookies, applying the RFC 6265 §5.2.3/§5.2.4 domain/path defaults
211/// relative to the response URL and dropping cookies whose declared
212/// domain does not cover it (`server/fetch.ts::_parseSetCookieHeader`).
213pub fn parse_set_cookie_headers(response_url: &reqwest::Url, headers: &reqwest::header::HeaderMap) -> Vec<Cookie> {
214  let hostname = response_url.host_str().unwrap_or("");
215  // RFC 6265 §5.1.4 default-path: directory of the request path.
216  let path = response_url.path();
217  let default_path = {
218    let trimmed = path.strip_prefix('/').unwrap_or(path);
219    let segments: Vec<&str> = trimmed.split('/').collect();
220    format!("/{}", segments[..segments.len().saturating_sub(1)].join("/"))
221  };
222  let mut cookies = Vec::new();
223  for value in headers.get_all(reqwest::header::SET_COOKIE) {
224    let Ok(raw) = value.to_str() else { continue };
225    let Some(mut cookie) = parse_raw_set_cookie(raw) else {
226      continue;
227    };
228    if cookie.domain.is_empty() {
229      // Host-only cookie: bare response hostname, exact-match semantics.
230      cookie.domain = hostname.to_string();
231    }
232    if !cookie_domain_matches(hostname, &cookie.domain) {
233      continue;
234    }
235    if cookie.path.is_empty() || !cookie.path.starts_with('/') {
236      cookie.path.clone_from(&default_path);
237    }
238    cookies.push(cookie);
239  }
240  cookies
241}
242
243#[cfg(test)]
244mod tests {
245  use super::*;
246
247  fn parse(header: &str, url: &str) -> Option<Cookie> {
248    let url = reqwest::Url::parse(url).unwrap();
249    let mut headers = reqwest::header::HeaderMap::new();
250    headers.append(reqwest::header::SET_COOKIE, header.parse().unwrap());
251    parse_set_cookie_headers(&url, &headers).into_iter().next()
252  }
253
254  #[test]
255  fn host_only_defaults_from_response_url() {
256    let c = parse("sid=abc", "http://example.com/a/b/c").unwrap();
257    assert_eq!(c.name, "sid");
258    assert_eq!(c.value, "abc");
259    // Host-only: bare hostname, exact-match semantics.
260    assert_eq!(c.domain, "example.com");
261    // RFC 6265 default-path: directory of the request path.
262    assert_eq!(c.path, "/a/b");
263    assert_eq!(c.expires, None);
264    assert!(matches!(c.same_site, Some(SameSite::Lax)));
265  }
266
267  #[test]
268  fn declared_domain_gets_dot_prefixed() {
269    let c = parse("sid=1; Domain=example.com; Path=/", "http://example.com/").unwrap();
270    assert_eq!(c.domain, ".example.com");
271    assert_eq!(c.path, "/");
272  }
273
274  #[test]
275  fn foreign_domain_is_dropped() {
276    assert!(parse("sid=1; Domain=evil.com", "http://example.com/").is_none());
277  }
278
279  #[test]
280  fn attributes_parse() {
281    let c = parse(
282      "a=b; Secure; HttpOnly; SameSite=Strict; Max-Age=3600; Path=/x",
283      "https://example.com/",
284    )
285    .unwrap();
286    assert!(c.secure);
287    assert!(c.http_only);
288    assert!(matches!(c.same_site, Some(SameSite::Strict)));
289    assert_eq!(c.path, "/x");
290    let now = std::time::SystemTime::now()
291      .duration_since(std::time::UNIX_EPOCH)
292      .unwrap()
293      .as_secs_f64();
294    let exp = c.expires.unwrap();
295    assert!(exp > now + 3500.0 && exp < now + 3700.0);
296  }
297
298  #[test]
299  fn non_positive_max_age_expires_immediately() {
300    let c = parse("a=b; Max-Age=0; Path=/", "http://example.com/").unwrap();
301    assert_eq!(c.expires, Some(0.0));
302    let c = parse("a=b; Max-Age=-5; Path=/", "http://example.com/").unwrap();
303    assert_eq!(c.expires, Some(0.0));
304  }
305
306  #[test]
307  fn expires_attribute_parses_http_date() {
308    let c = parse(
309      "a=b; Expires=Wed, 01 Jan 2031 00:00:00 GMT; Path=/",
310      "http://example.com/",
311    )
312    .unwrap();
313    let exp = c.expires.unwrap();
314    assert!((1_924_991_940.0..1_924_993_000.0).contains(&exp), "got {exp}");
315  }
316
317  #[test]
318  fn matcher_domain_and_path() {
319    let mk = |domain: &str, path: &str, secure: bool| Cookie {
320      name: "n".into(),
321      value: "v".into(),
322      domain: domain.into(),
323      path: path.into(),
324      secure,
325      http_only: false,
326      expires: None,
327      same_site: None,
328      url: None,
329    };
330    let url = |u: &str| reqwest::Url::parse(u).unwrap();
331    // Host-only: exact host, never subdomains.
332    assert!(cookie_matches_url(
333      &mk("example.com", "/", false),
334      &url("http://example.com/")
335    ));
336    assert!(!cookie_matches_url(
337      &mk("example.com", "/", false),
338      &url("http://sub.example.com/")
339    ));
340    // Dot-prefixed: apex + subdomains.
341    assert!(cookie_matches_url(
342      &mk(".example.com", "/", false),
343      &url("http://sub.example.com/")
344    ));
345    assert!(cookie_matches_url(
346      &mk(".example.com", "/", false),
347      &url("http://example.com/")
348    ));
349    // Path prefix on segment boundary.
350    assert!(cookie_matches_url(&mk("e.com", "/a", false), &url("http://e.com/a/b")));
351    assert!(!cookie_matches_url(&mk("e.com", "/a", false), &url("http://e.com/ab")));
352    // Secure only over https, with the localhost carve-out.
353    assert!(!cookie_matches_url(&mk("e.com", "/", true), &url("http://e.com/")));
354    assert!(cookie_matches_url(&mk("e.com", "/", true), &url("https://e.com/")));
355    assert!(cookie_matches_url(
356      &mk("localhost", "/", true),
357      &url("http://localhost/")
358    ));
359  }
360
361  #[test]
362  fn expired_cookie_is_not_sent() {
363    let c = Cookie {
364      name: "n".into(),
365      value: "v".into(),
366      domain: "e.com".into(),
367      path: "/".into(),
368      secure: false,
369      http_only: false,
370      expires: Some(1.0),
371      same_site: None,
372      url: None,
373    };
374    assert!(!cookie_matches_url(&c, &reqwest::Url::parse("http://e.com/").unwrap()));
375    // Backends report session cookies as -1: never expired.
376    let session = Cookie {
377      expires: Some(-1.0),
378      ..c
379    };
380    assert!(cookie_matches_url(
381      &session,
382      &reqwest::Url::parse("http://e.com/").unwrap()
383    ));
384  }
385}