CookieJar stored cookies as domain -> name -> entry, so a second Set-Cookie with the same name but a different path silently overwrote the first (RFC 6265 §5.3 identifies a cookie by name+domain+path). The retrieval side already treated path as part of identity — get_cookie_header path-matches, and delete_cookies_filtered keys on (name, path) — so only storage lost cookies. Rekey the inner map by (name, path). A Max-Age=0 / expired Set-Cookie now deletes exactly the (name, path) it targets instead of every same-name cookie; delete_cookie(name) still clears all paths (retain by entry name). Adds tests: same-name/different-path coexistence and no cross-path leak, same-name/same-path replacement, and path-scoped deletion leaving a sibling-path cookie intact.
This commit is contained in:
1 parent
57949a0efc
commit
c8e482fde9
1 file changed
+64
-13
@@ -17,7 +17,11 @@ fn normalize_same_site(value: &str) -> String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub struct CookieJar {
|
pub struct CookieJar {
|
||||||
cookies: RwLock<HashMap<String, HashMap<String, CookieEntry>>>,
|
/// domain -> (name, path) -> entry. RFC 6265 §5.3 identifies a cookie by
|
||||||
|
/// (name, domain, path); the outer map scopes by domain and the inner key
|
||||||
|
/// carries name+path so same-name cookies on different paths coexist
|
||||||
|
/// instead of clobbering each other.
|
||||||
|
cookies: RwLock<HashMap<String, HashMap<(String, String), CookieEntry>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||||
@@ -116,7 +120,7 @@ impl CookieJar {
|
|||||||
if exp == 0 {
|
if exp == 0 {
|
||||||
let mut cookies = self.cookies.write().unwrap();
|
let mut cookies = self.cookies.write().unwrap();
|
||||||
if let Some(domain_cookies) = cookies.get_mut(&domain) {
|
if let Some(domain_cookies) = cookies.get_mut(&domain) {
|
||||||
domain_cookies.remove(&name);
|
domain_cookies.remove(&(name.clone(), path.clone()));
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -132,7 +136,7 @@ impl CookieJar {
|
|||||||
let entry = CookieEntry {
|
let entry = CookieEntry {
|
||||||
name: name.clone(),
|
name: name.clone(),
|
||||||
value,
|
value,
|
||||||
path,
|
path: path.clone(),
|
||||||
domain: domain.clone(),
|
domain: domain.clone(),
|
||||||
host_only,
|
host_only,
|
||||||
secure,
|
secure,
|
||||||
@@ -142,7 +146,7 @@ impl CookieJar {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let mut cookies = self.cookies.write().unwrap();
|
let mut cookies = self.cookies.write().unwrap();
|
||||||
cookies.entry(domain).or_default().insert(name, entry);
|
cookies.entry(domain).or_default().insert((name, path), entry);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_cookie_header(&self, url: &Url) -> String {
|
pub fn get_cookie_header(&self, url: &Url) -> String {
|
||||||
@@ -216,7 +220,7 @@ impl CookieJar {
|
|||||||
let entry = CookieEntry {
|
let entry = CookieEntry {
|
||||||
name: cookie.name.clone(),
|
name: cookie.name.clone(),
|
||||||
value: cookie.value,
|
value: cookie.value,
|
||||||
path: cookie.path,
|
path: cookie.path.clone(),
|
||||||
domain: cookie.domain.clone(),
|
domain: cookie.domain.clone(),
|
||||||
// CDP/persisted import is trusted; honor the explicit domain as
|
// CDP/persisted import is trusted; honor the explicit domain as
|
||||||
// domain-scoped (matches the prior behavior).
|
// domain-scoped (matches the prior behavior).
|
||||||
@@ -226,7 +230,7 @@ impl CookieJar {
|
|||||||
expires,
|
expires,
|
||||||
same_site,
|
same_site,
|
||||||
};
|
};
|
||||||
jar.entry(cookie.domain).or_default().insert(cookie.name, entry);
|
jar.entry(cookie.domain).or_default().insert((cookie.name, cookie.path), entry);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -339,7 +343,7 @@ impl CookieJar {
|
|||||||
if exp == 0 {
|
if exp == 0 {
|
||||||
let mut cookies = self.cookies.write().unwrap();
|
let mut cookies = self.cookies.write().unwrap();
|
||||||
if let Some(domain_cookies) = cookies.get_mut(&domain) {
|
if let Some(domain_cookies) = cookies.get_mut(&domain) {
|
||||||
domain_cookies.remove(&name);
|
domain_cookies.remove(&(name.clone(), path.clone()));
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -355,7 +359,7 @@ impl CookieJar {
|
|||||||
let entry = CookieEntry {
|
let entry = CookieEntry {
|
||||||
name: name.clone(),
|
name: name.clone(),
|
||||||
value,
|
value,
|
||||||
path,
|
path: path.clone(),
|
||||||
domain: domain.clone(),
|
domain: domain.clone(),
|
||||||
host_only,
|
host_only,
|
||||||
secure,
|
secure,
|
||||||
@@ -365,14 +369,14 @@ impl CookieJar {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let mut cookies = self.cookies.write().unwrap();
|
let mut cookies = self.cookies.write().unwrap();
|
||||||
cookies.entry(domain).or_default().insert(name, entry);
|
cookies.entry(domain).or_default().insert((name, path), entry);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn delete_cookie(&self, name: &str, domain: &str) {
|
pub fn delete_cookie(&self, name: &str, domain: &str) {
|
||||||
let mut cookies = self.cookies.write().unwrap();
|
let mut cookies = self.cookies.write().unwrap();
|
||||||
if domain.is_empty() {
|
if domain.is_empty() {
|
||||||
for domain_cookies in cookies.values_mut() {
|
for domain_cookies in cookies.values_mut() {
|
||||||
domain_cookies.remove(name);
|
domain_cookies.retain(|_k, e| e.name != name);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
let domains_to_try = [
|
let domains_to_try = [
|
||||||
@@ -382,7 +386,7 @@ impl CookieJar {
|
|||||||
];
|
];
|
||||||
for d in &domains_to_try {
|
for d in &domains_to_try {
|
||||||
if let Some(domain_cookies) = cookies.get_mut(d.as_str()) {
|
if let Some(domain_cookies) = cookies.get_mut(d.as_str()) {
|
||||||
domain_cookies.remove(name);
|
domain_cookies.retain(|_k, e| e.name != name);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -396,7 +400,7 @@ impl CookieJar {
|
|||||||
};
|
};
|
||||||
if domain.is_empty() {
|
if domain.is_empty() {
|
||||||
for domain_cookies in cookies.values_mut() {
|
for domain_cookies in cookies.values_mut() {
|
||||||
domain_cookies.retain(|n, e| !(n == name && matches_path(&e.path)));
|
domain_cookies.retain(|_k, e| !(e.name == name && matches_path(&e.path)));
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
let domains_to_try = [
|
let domains_to_try = [
|
||||||
@@ -406,7 +410,7 @@ impl CookieJar {
|
|||||||
];
|
];
|
||||||
for d in &domains_to_try {
|
for d in &domains_to_try {
|
||||||
if let Some(domain_cookies) = cookies.get_mut(d.as_str()) {
|
if let Some(domain_cookies) = cookies.get_mut(d.as_str()) {
|
||||||
domain_cookies.retain(|n, e| !(n == name && matches_path(&e.path)));
|
domain_cookies.retain(|_k, e| !(e.name == name && matches_path(&e.path)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -705,6 +709,53 @@ mod tests {
|
|||||||
assert!(jar.get_cookie_header(&url).is_empty());
|
assert!(jar.get_cookie_header(&url).is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_same_name_cookies_with_different_paths_coexist() {
|
||||||
|
// RFC 6265 §5.3: a cookie is identified by (name, domain, path). Two
|
||||||
|
// cookies that share a name but differ in path are distinct and must
|
||||||
|
// both be retained — storing by name alone clobbers the first.
|
||||||
|
let jar = CookieJar::new();
|
||||||
|
let set_url = Url::parse("https://example.com/").unwrap();
|
||||||
|
jar.set_cookie("id=1; Path=/a", &set_url);
|
||||||
|
jar.set_cookie("id=2; Path=/b", &set_url);
|
||||||
|
|
||||||
|
let header_a = jar.get_cookie_header(&Url::parse("https://example.com/a/page").unwrap());
|
||||||
|
let header_b = jar.get_cookie_header(&Url::parse("https://example.com/b/page").unwrap());
|
||||||
|
assert!(header_a.contains("id=1"), "/a must see the Path=/a cookie, got: {header_a:?}");
|
||||||
|
assert!(header_b.contains("id=2"), "/b must see the Path=/b cookie, got: {header_b:?}");
|
||||||
|
assert!(!header_a.contains("id=2"), "Path=/b cookie leaked to /a: {header_a:?}");
|
||||||
|
assert!(!header_b.contains("id=1"), "Path=/a cookie leaked to /b: {header_b:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_same_name_same_path_cookie_is_replaced() {
|
||||||
|
// Same (name, path): the newer value replaces the older one — still
|
||||||
|
// one entry, not two.
|
||||||
|
let jar = CookieJar::new();
|
||||||
|
let url = Url::parse("https://example.com/a/x").unwrap();
|
||||||
|
jar.set_cookie("id=1; Path=/a", &url);
|
||||||
|
jar.set_cookie("id=2; Path=/a", &url);
|
||||||
|
let header = jar.get_cookie_header(&url);
|
||||||
|
assert!(header.contains("id=2"), "newer value must win: {header:?}");
|
||||||
|
assert!(!header.contains("id=1"), "old value must be replaced: {header:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_max_age_zero_deletes_only_matching_path() {
|
||||||
|
// A Max-Age=0 Set-Cookie deletes the (name, path) it targets, leaving a
|
||||||
|
// same-name cookie on a different path intact.
|
||||||
|
let jar = CookieJar::new();
|
||||||
|
let set_url = Url::parse("https://example.com/").unwrap();
|
||||||
|
jar.set_cookie("id=1; Path=/a", &set_url);
|
||||||
|
jar.set_cookie("id=2; Path=/b", &set_url);
|
||||||
|
jar.set_cookie("id=x; Path=/a; Max-Age=0", &set_url);
|
||||||
|
|
||||||
|
let header_a = jar.get_cookie_header(&Url::parse("https://example.com/a/page").unwrap());
|
||||||
|
let header_b = jar.get_cookie_header(&Url::parse("https://example.com/b/page").unwrap());
|
||||||
|
assert!(header_a.is_empty(), "Path=/a cookie should be deleted: {header_a:?}");
|
||||||
|
assert!(header_b.contains("id=2"), "Path=/b cookie must survive: {header_b:?}");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_max_age_sets_expiry() {
|
fn test_max_age_sets_expiry() {
|
||||||
let jar = CookieJar::new();
|
let jar = CookieJar::new();
|
||||||
|
|||||||
Reference in new issue
Block a user