Use cookie_store crate instead of cookie::CookieJar (#169)

CookieJar doesn't support the path-match and domain-match algorithms from [RFC 6265](https://tools.ietf.org/html/rfc6265#section-5.1.3), while cookie_store does.

This fixes some issues with the cookie matching algorithm currently in ureq. For instance,
the domain-match uses substring matching rather than the RFC 6265 algorithm.

This deletes two tests:

match_cookies_returns_nothing_when_no_cookies didn't test much
agent_cookies was failing because cookie_store rejects cookies on the `test:` scheme.
  The way around this is to set up a testserver - but it turns out cookies_on_redirect already
  does that, and covers the same cases and more.

This changes some cookie-related behavior:

 - Cookies could previously be sent to a wrong domain - e.g. a cookie set on `example.com`
  could go to `example.com.evil.com` or `evilexample.com`. Probably no one was relying on
  this, since it's quite broken.
 - A cookie with a path of `/foo` could be sent on a request to `/foobar`, but now it can't.
 - Cookies could previously be set on IP addresses, but now they can't.
 - Cookies could previously be set for domains other than the one on the request (or its
  parents), but now they can't.
 - When a cookie had no domain attribute, it would previously get the domain from the
  request, and subsequently be sent to that domain and all subdomains. Now, it will only
  be sent to that exact domain (host-only).

That last one is probably the most likely to break people, since someone could depend
on it without realizing it was broken behavior.
This commit is contained in:
Jacob Hoffman-Andrews
2020-10-04 10:21:09 -07:00
committed by GitHub
4 changed files with 87 additions and 131 deletions

View File

@@ -20,7 +20,7 @@ json = ["serde", "serde_json"]
charset = ["encoding"] charset = ["encoding"]
tls = ["rustls", "webpki", "webpki-roots"] tls = ["rustls", "webpki", "webpki-roots"]
native-certs = ["rustls-native-certs"] native-certs = ["rustls-native-certs"]
cookies = ["cookie"] cookies = ["cookie", "cookie_store"]
socks-proxy = ["socks"] socks-proxy = ["socks"]
[dependencies] [dependencies]
@@ -39,6 +39,7 @@ serde = { version = "1", optional = true }
serde_json = { version = "1", optional = true } serde_json = { version = "1", optional = true }
encoding = { version = "0.2", optional = true } encoding = { version = "0.2", optional = true }
native-tls = { version = "0.2", optional = true } native-tls = { version = "0.2", optional = true }
cookie_store = { version = "0.12.0", optional = true }
log = "0.4.11" log = "0.4.11"
[dev-dependencies] [dev-dependencies]

View File

@@ -1,7 +1,11 @@
#[cfg(feature = "cookie")] #[cfg(feature = "cookie")]
use cookie::{Cookie, CookieJar}; use cookie::Cookie;
#[cfg(feature = "cookie")]
use cookie_store::CookieStore;
use std::sync::Arc; use std::sync::Arc;
use std::sync::Mutex; use std::sync::Mutex;
#[cfg(feature = "cookie")]
use url::Url;
use crate::header::{self, Header}; use crate::header::{self, Header};
use crate::pool::ConnectionPool; use crate::pool::ConnectionPool;
@@ -52,8 +56,9 @@ pub(crate) struct AgentState {
/// Reused connections between requests. /// Reused connections between requests.
pub(crate) pool: ConnectionPool, pub(crate) pool: ConnectionPool,
/// Cookies saved between requests. /// Cookies saved between requests.
/// Invariant: All cookies must have a nonempty domain and path.
#[cfg(feature = "cookie")] #[cfg(feature = "cookie")]
pub(crate) jar: CookieJar, pub(crate) jar: CookieStore,
pub(crate) resolver: ArcResolver, pub(crate) resolver: ArcResolver,
} }
@@ -219,6 +224,9 @@ impl Agent {
/// either by setting it in the agent, or by making requests /// either by setting it in the agent, or by making requests
/// that `Set-Cookie` in the agent. /// that `Set-Cookie` in the agent.
/// ///
/// Note that this will return any cookie for the given name,
/// regardless of which host and path that cookie was set on.
///
/// ``` /// ```
/// let agent = ureq::agent(); /// let agent = ureq::agent();
/// ///
@@ -229,21 +237,53 @@ impl Agent {
#[cfg(feature = "cookie")] #[cfg(feature = "cookie")]
pub fn cookie(&self, name: &str) -> Option<Cookie<'static>> { pub fn cookie(&self, name: &str) -> Option<Cookie<'static>> {
let state = self.state.lock().unwrap(); let state = self.state.lock().unwrap();
state.jar.get(name).cloned() let first_found = state.jar.iter_any().find(|c| c.name() == name);
if let Some(first_found) = first_found {
let c: &Cookie = &*first_found;
Some(c.clone())
} else {
None
}
} }
/// Set a cookie in this agent. /// Set a cookie in this agent.
/// ///
/// Cookies without a domain, or with a malformed domain or path,
/// will be silently ignored.
///
/// ``` /// ```
/// let agent = ureq::agent(); /// let agent = ureq::agent();
/// ///
/// let cookie = ureq::Cookie::new("name", "value"); /// let cookie = ureq::Cookie::build("name", "value")
/// .domain("example.com")
/// .path("/")
/// .secure(true)
/// .finish();
/// agent.set_cookie(cookie); /// agent.set_cookie(cookie);
/// ``` /// ```
#[cfg(feature = "cookie")] #[cfg(feature = "cookie")]
pub fn set_cookie(&self, cookie: Cookie<'static>) { pub fn set_cookie(&self, cookie: Cookie<'static>) {
let mut cookie = cookie.clone();
if cookie.domain().is_none() {
return;
}
if cookie.path().is_none() {
cookie.set_path("/");
}
let path = cookie.path().unwrap();
let domain = cookie.domain().unwrap();
let fake_url: Url = match format!("http://{}{}", domain, path).parse() {
Ok(u) => u,
Err(_) => return,
};
let mut state = self.state.lock().unwrap(); let mut state = self.state.lock().unwrap();
state.jar.add_original(cookie); let cs_cookie = match cookie_store::Cookie::try_from_raw_cookie(&cookie, &fake_url) {
Ok(c) => c,
Err(_) => return,
};
state.jar.insert(cs_cookie, &fake_url).ok();
} }
/// Make a GET request from this agent. /// Make a GET request from this agent.

View File

@@ -31,34 +31,6 @@ fn agent_reuse_headers() {
assert_eq!(resp.header("X-Call").unwrap(), "2"); assert_eq!(resp.header("X-Call").unwrap(), "2");
} }
#[cfg(feature = "cookie")]
#[test]
fn agent_cookies() {
let agent = agent();
test::set_handler("/agent_cookies", |_unit| {
test::make_response(
200,
"OK",
vec!["Set-Cookie: foo=bar%20baz; Path=/; HttpOnly"],
vec![],
)
});
agent.get("test://host/agent_cookies").call();
assert!(agent.cookie("foo").is_some());
assert_eq!(agent.cookie("foo").unwrap().value(), "bar baz");
test::set_handler("/agent_cookies", |unit| {
assert!(unit.has("cookie"));
assert_eq!(unit.header("cookie").unwrap(), "foo=bar%20baz");
test::make_response(200, "OK", vec![], vec![])
});
agent.get("test://host/agent_cookies").call();
}
// Handler that answers with a simple HTTP response, and times // Handler that answers with a simple HTTP response, and times
// out idle connections after 2 seconds. // out idle connections after 2 seconds.
fn idle_timeout_handler(mut stream: TcpStream) -> io::Result<()> { fn idle_timeout_handler(mut stream: TcpStream) -> io::Result<()> {

View File

@@ -6,8 +6,9 @@ use qstring::QString;
use url::Url; use url::Url;
#[cfg(feature = "cookie")] #[cfg(feature = "cookie")]
use cookie::{Cookie, CookieJar}; use cookie::Cookie;
#[cfg(feature = "cookie")]
use crate::agent::AgentState; use crate::agent::AgentState;
use crate::body::{self, BodySize, Payload, SizedReader}; use crate::body::{self, BodySize, Payload, SizedReader};
use crate::header; use crate::header;
@@ -49,10 +50,6 @@ impl Unit {
let query_string = combine_query(&url, &req.query, mix_queries); let query_string = combine_query(&url, &req.query, mix_queries);
let cookie_header: Option<Header> = url
.host_str()
.and_then(|host_str| extract_cookies(&req.agent, &url.scheme(), host_str, &url.path()));
let extra_headers = { let extra_headers = {
let mut extra = vec![]; let mut extra = vec![];
@@ -84,12 +81,15 @@ impl Unit {
extra.push(Header::new("Authorization", &format!("Basic {}", encoded))); extra.push(Header::new("Authorization", &format!("Basic {}", encoded)));
} }
#[cfg(feature = "cookie")]
extra.extend(extract_cookies(&req.agent, &url).into_iter());
extra extra
}; };
let headers: Vec<_> = req let headers: Vec<_> = req
.headers .headers
.iter() .iter()
.chain(cookie_header.iter())
.chain(extra_headers.iter()) .chain(extra_headers.iter())
.cloned() .cloned()
.collect(); .collect();
@@ -198,6 +198,7 @@ pub(crate) fn connect(
} }
// squirrel away cookies // squirrel away cookies
#[cfg(feature = "cookie")]
save_cookies(&unit, &resp); save_cookies(&unit, &resp);
// handle redirects // handle redirects
@@ -250,62 +251,18 @@ pub(crate) fn connect(
} }
#[cfg(feature = "cookie")] #[cfg(feature = "cookie")]
fn extract_cookies( fn extract_cookies(state: &std::sync::Mutex<AgentState>, url: &Url) -> Option<Header> {
state: &std::sync::Mutex<AgentState>,
scheme: &str,
host: &str,
path: &str,
) -> Option<Header> {
let state = state.lock().unwrap(); let state = state.lock().unwrap();
let is_secure = scheme.eq_ignore_ascii_case("https"); let header_value = state
.jar
match_cookies(&state.jar, host, path, is_secure) .get_request_cookies(url)
} .map(|c| Cookie::new(c.name(), c.value()).encoded().to_string())
#[cfg(not(feature = "cookie"))]
fn extract_cookies(
_state: &std::sync::Mutex<AgentState>,
_scheme: &str,
_host: &str,
_path: &str,
) -> Option<Header> {
None
}
// TODO check so cookies can't be set for tld:s
#[cfg(feature = "cookie")]
fn match_cookies(jar: &CookieJar, domain: &str, path: &str, is_secure: bool) -> Option<Header> {
Some(
jar.iter()
.filter(|c| {
// if there is a domain, it must be matched.
// if there is no domain, then ignore cookie
let domain_ok = c
.domain()
.map(|cdom| domain.contains(cdom))
.unwrap_or(false);
// a path must match the beginning of request path.
// no cookie path, we say is ok. is it?!
let path_ok = c
.path()
.map(|cpath| path.find(cpath).map(|pos| pos == 0).unwrap_or(false))
.unwrap_or(true);
// either the cookie isnt secure, or we're not doing a secure request.
let secure_ok = !c.secure().unwrap_or(false) || is_secure;
domain_ok && path_ok && secure_ok
})
.map(|c| {
let name = c.name().to_string();
let value = c.value().to_string();
let nameval = Cookie::new(name, value).encoded().to_string();
nameval
})
.collect::<Vec<_>>() .collect::<Vec<_>>()
.join(";"), .join(";");
) match header_value.as_str() {
.filter(|x| !x.is_empty()) "" => None,
.map(|s| Header::new("Cookie", &s)) val => Some(Header::new("Cookie", val)),
}
} }
/// Combine the query of the url and the query options set on the request object. /// Combine the query of the url and the query options set on the request object.
@@ -410,36 +367,24 @@ fn send_prelude(unit: &Unit, stream: &mut Stream, redir: bool) -> io::Result<()>
Ok(()) Ok(())
} }
#[cfg(not(feature = "cookie"))]
fn save_cookies(_unit: &Unit, _resp: &Response) {}
/// Investigate a response for "Set-Cookie" headers. /// Investigate a response for "Set-Cookie" headers.
#[cfg(feature = "cookie")] #[cfg(feature = "cookie")]
fn save_cookies(unit: &Unit, resp: &Response) { fn save_cookies(unit: &Unit, resp: &Response) {
// //
let cookies = resp.all("set-cookie"); let headers = resp.all("set-cookie");
if cookies.is_empty() { // Avoid locking if there are no cookie headers
if headers.is_empty() {
return; return;
} }
let cookies = headers.into_iter().flat_map(|header_value| {
// only lock if we know there is something to process match Cookie::parse(header_value.to_string()) {
Err(_) => None,
Ok(c) => Some(c),
}
});
let state = &mut unit.req.agent.lock().unwrap(); let state = &mut unit.req.agent.lock().unwrap();
for raw_cookie in cookies.iter() { state.jar.store_response_cookies(cookies, &unit.url.clone());
let to_parse = if raw_cookie.to_lowercase().contains("domain=") {
(*raw_cookie).to_string()
} else {
let host = &unit.url.host_str().unwrap().to_string();
format!("{}; Domain={}", raw_cookie, host)
};
match Cookie::parse_encoded(&to_parse[..]) {
Err(_) => (), // ignore unparseable cookies
Ok(cookie) => {
let cookie = cookie.into_owned();
state.jar.add(cookie)
}
}
}
} }
#[cfg(test)] #[cfg(test)]
@@ -447,27 +392,25 @@ fn save_cookies(unit: &Unit, resp: &Response) {
mod tests { mod tests {
use super::*; use super::*;
use crate::Agent;
///////////////////// COOKIE TESTS ////////////////////////////// ///////////////////// COOKIE TESTS //////////////////////////////
#[test]
fn match_cookies_returns_nothing_when_no_cookies() {
let jar = CookieJar::new();
let result = match_cookies(&jar, "crates.io", "/", false);
assert_eq!(result, None);
}
#[test] #[test]
fn match_cookies_returns_one_header() { fn match_cookies_returns_one_header() {
let mut jar = CookieJar::new(); let agent = Agent::default();
let cookie1 = Cookie::parse("cookie1=value1; Domain=crates.io").unwrap(); let url: Url = "https://crates.io/".parse().unwrap();
let cookie2 = Cookie::parse("cookie2=value2; Domain=crates.io").unwrap(); let cookie1: Cookie = "cookie1=value1; Domain=crates.io; Path=/".parse().unwrap();
jar.add(cookie1); let cookie2: Cookie = "cookie2=value2; Domain=crates.io; Path=/".parse().unwrap();
jar.add(cookie2); agent
.state
.lock()
.unwrap()
.jar
.store_response_cookies(vec![cookie1, cookie2].into_iter(), &url);
// There's no guarantee to the order in which cookies are defined. // There's no guarantee to the order in which cookies are defined.
// Ensure that they're either in one order or the other. // Ensure that they're either in one order or the other.
let result = match_cookies(&jar, "crates.io", "/", false); let result = extract_cookies(&agent.state, &url);
let order1 = "cookie1=value1;cookie2=value2"; let order1 = "cookie1=value1;cookie2=value2";
let order2 = "cookie2=value2;cookie1=value1"; let order2 = "cookie2=value2;cookie1=value1";