cookie_store/
cookie_domain.rs

1use std;
2
3use cookie::Cookie as RawCookie;
4use idna;
5#[cfg(feature = "public_suffix")]
6use publicsuffix::{List, Psl, Suffix};
7#[cfg(feature = "serde")]
8use serde_derive::{Deserialize, Serialize};
9use std::convert::TryFrom;
10use url::{Host, Url};
11
12use crate::utils::is_host_name;
13use crate::CookieError;
14
15pub fn is_match(domain: &str, request_url: &Url) -> bool {
16    CookieDomain::try_from(domain)
17        .map(|domain| domain.matches(request_url))
18        .unwrap_or(false)
19}
20
21/// The domain of a `Cookie`
22#[derive(PartialEq, Eq, Clone, Debug, Hash, PartialOrd, Ord)]
23#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
24pub enum CookieDomain {
25    /// No Domain attribute in Set-Cookie header
26    HostOnly(String),
27    /// Domain attribute from Set-Cookie header
28    Suffix(String),
29    /// Domain attribute was not present in the Set-Cookie header
30    NotPresent,
31    /// Domain attribute-value was empty; technically undefined behavior, but suggested that this
32    /// be treated as invalid
33    Empty,
34}
35
36// 5.1.3.  Domain Matching
37// A string domain-matches a given domain string if at least one of the
38// following conditions hold:
39//
40// o  The domain string and the string are identical.  (Note that both
41//    the domain string and the string will have been canonicalized to
42//    lower case at this point.)
43//
44// o  All of the following conditions hold:
45//
46//    *  The domain string is a suffix of the string.
47//
48//    *  The last character of the string that is not included in the
49//       domain string is a %x2E (".") character.
50//
51//    *  The string is a host name (i.e., not an IP address).
52/// The concept of a domain match per [IETF RFC6265 Section
53/// 5.1.3](https://datatracker.ietf.org/doc/html/rfc6265#section-5.1.3)
54impl CookieDomain {
55    /// Get the CookieDomain::HostOnly variant based on `request_url`. This is the effective behavior of
56    /// setting the domain-attribute to empty
57    pub fn host_only(request_url: &Url) -> Result<CookieDomain, CookieError> {
58        request_url
59            .host()
60            .ok_or(CookieError::NonRelativeScheme)
61            .map(|h| match h {
62                Host::Domain(d) => CookieDomain::HostOnly(d.into()),
63                Host::Ipv4(addr) => CookieDomain::HostOnly(format!("{}", addr)),
64                Host::Ipv6(addr) => CookieDomain::HostOnly(format!("[{}]", addr)),
65            })
66    }
67
68    /// Tests if the given `url::Url` meets the domain-match criteria
69    pub fn matches(&self, request_url: &Url) -> bool {
70        if let Some(url_host) = request_url.host_str() {
71            match *self {
72                CookieDomain::HostOnly(ref host) => host == url_host,
73                CookieDomain::Suffix(ref suffix) => {
74                    suffix == url_host
75                        || (is_host_name(url_host)
76                            && url_host.ends_with(suffix)
77                            && url_host[(url_host.len() - suffix.len() - 1)..].starts_with('.'))
78                }
79                CookieDomain::NotPresent | CookieDomain::Empty => false, // nothing can match the Empty case
80            }
81        } else {
82            false // not a matchable scheme
83        }
84    }
85
86    /// Tests if the given `url::Url` has a request-host identical to the domain attribute
87    pub fn host_is_identical(&self, request_url: &Url) -> bool {
88        if let Some(url_host) = request_url.host_str() {
89            match *self {
90                CookieDomain::HostOnly(ref host) => host == url_host,
91                CookieDomain::Suffix(ref suffix) => suffix == url_host,
92                CookieDomain::NotPresent | CookieDomain::Empty => false, // nothing can match the Empty case
93            }
94        } else {
95            false // not a matchable scheme
96        }
97    }
98
99    /// Tests if the domain-attribute is a public suffix as indicated by the provided
100    /// `publicsuffix::List`.
101    #[cfg(feature = "public_suffix")]
102    pub fn is_public_suffix(&self, psl: &List) -> bool {
103        if let Some(domain) = self.as_cow().as_ref().map(|d| d.as_bytes()) {
104            psl.suffix(domain)
105                // Only consider suffixes explicitly listed in the public suffix list
106                // to avoid issues like https://github.com/curl/curl/issues/658
107                .filter(Suffix::is_known)
108                .filter(|suffix| suffix == &domain)
109                .is_some()
110        } else {
111            false
112        }
113    }
114
115    /// Get a borrowed string representation of the domain. For `Empty` and `NotPresent` variants,
116    /// `None` shall be returned;
117    pub fn as_cow(&self) -> Option<std::borrow::Cow<'_, str>> {
118        match *self {
119            CookieDomain::HostOnly(ref s) | CookieDomain::Suffix(ref s) => {
120                Some(std::borrow::Cow::Borrowed(s))
121            }
122            CookieDomain::Empty | CookieDomain::NotPresent => None,
123        }
124    }
125}
126
127/// Construct a `CookieDomain::Suffix` from a string, stripping a single leading '.' if present.
128/// If the source string is empty, returns the `CookieDomain::Empty` variant.
129impl<'a> TryFrom<&'a str> for CookieDomain {
130    type Error = crate::Error;
131    fn try_from(value: &str) -> Result<CookieDomain, Self::Error> {
132        idna::domain_to_ascii(value.trim())
133            .map_err(super::IdnaErrors::from)
134            .map_err(Into::into)
135            .map(|domain| {
136                if domain.is_empty() || "." == domain {
137                    CookieDomain::Empty
138                } else if domain.starts_with('.') {
139                    CookieDomain::Suffix(String::from(&domain[1..]))
140                } else {
141                    CookieDomain::Suffix(domain)
142                }
143            })
144    }
145}
146
147/// Construct a `CookieDomain::Suffix` from a `cookie::Cookie`, which handles stripping a leading
148/// '.' for us. If the cookie.domain is None or an empty string, the `CookieDomain::Empty` variant
149/// is returned.
150/// __NOTE__: `cookie::Cookie` domain values already have the leading '.' stripped. To avoid
151/// performing this step twice, the `From<&cookie::Cookie>` impl should be used,
152/// instead of passing `cookie.domain` to the `From<&str>` impl.
153impl<'a, 'c> TryFrom<&'a RawCookie<'c>> for CookieDomain {
154    type Error = crate::Error;
155    fn try_from(cookie: &'a RawCookie<'c>) -> Result<CookieDomain, Self::Error> {
156        if let Some(domain) = cookie.domain() {
157            idna::domain_to_ascii(domain.trim())
158                .map_err(super::IdnaErrors::from)
159                .map_err(Into::into)
160                .map(|domain| {
161                    if domain.is_empty() {
162                        CookieDomain::Empty
163                    } else {
164                        CookieDomain::Suffix(domain)
165                    }
166                })
167        } else {
168            Ok(CookieDomain::NotPresent)
169        }
170    }
171}
172
173impl<'a> From<&'a CookieDomain> for String {
174    fn from(c: &'a CookieDomain) -> String {
175        match *c {
176            CookieDomain::HostOnly(ref h) => h.to_owned(),
177            CookieDomain::Suffix(ref s) => s.to_owned(),
178            CookieDomain::Empty | CookieDomain::NotPresent => "".to_owned(),
179        }
180    }
181}
182
183#[cfg(test)]
184mod tests {
185    use cookie::Cookie as RawCookie;
186    use std::convert::TryFrom;
187    use url::Url;
188
189    use super::CookieDomain;
190    use crate::utils::test::*;
191
192    #[inline]
193    fn matches(expected: bool, cookie_domain: &CookieDomain, url: &str) {
194        let url = Url::parse(url).unwrap();
195        assert!(
196            expected == cookie_domain.matches(&url),
197            "cookie_domain: {:?} url: {:?}, url.host_str(): {:?}",
198            cookie_domain,
199            url,
200            url.host_str()
201        );
202    }
203
204    #[inline]
205    fn variants(expected: bool, cookie_domain: &CookieDomain, url: &str) {
206        matches(expected, cookie_domain, url);
207        matches(expected, cookie_domain, &format!("{}/", url));
208        matches(expected, cookie_domain, &format!("{}:8080", url));
209        matches(expected, cookie_domain, &format!("{}/foo/bar", url));
210        matches(expected, cookie_domain, &format!("{}:8080/foo/bar", url));
211    }
212
213    #[test]
214    fn matches_hostonly() {
215        {
216            let url = url("http://example.com");
217            // HostOnly must be an identical string match, and may be an IP address
218            // or a hostname
219            let host_name = CookieDomain::host_only(&url).expect("unable to parse domain");
220            matches(false, &host_name, "data:nonrelative");
221            variants(true, &host_name, "http://example.com");
222            variants(false, &host_name, "http://example.org");
223            // per RFC6265:
224            //    WARNING: Some existing user agents treat an absent Domain
225            //      attribute as if the Domain attribute were present and contained
226            //      the current host name.  For example, if example.com returns a Set-
227            //      Cookie header without a Domain attribute, these user agents will
228            //      erroneously send the cookie to www.example.com as well.
229            variants(false, &host_name, "http://foo.example.com");
230            variants(false, &host_name, "http://127.0.0.1");
231            variants(false, &host_name, "http://[::1]");
232        }
233
234        {
235            let url = url("http://127.0.0.1");
236            let ip4 = CookieDomain::host_only(&url).expect("unable to parse Ipv4");
237            matches(false, &ip4, "data:nonrelative");
238            variants(true, &ip4, "http://127.0.0.1");
239            variants(false, &ip4, "http://[::1]");
240        }
241
242        {
243            let url = url("http://[::1]");
244            let ip6 = CookieDomain::host_only(&url).expect("unable to parse Ipv6");
245            matches(false, &ip6, "data:nonrelative");
246            variants(false, &ip6, "http://127.0.0.1");
247            variants(true, &ip6, "http://[::1]");
248        }
249    }
250
251    #[test]
252    fn from_strs() {
253        assert_eq!(
254            CookieDomain::Empty,
255            CookieDomain::try_from("").expect("unable to parse domain")
256        );
257        assert_eq!(
258            CookieDomain::Empty,
259            CookieDomain::try_from(".").expect("unable to parse domain")
260        );
261        // per [IETF RFC6265 Section 5.2.3](https://datatracker.ietf.org/doc/html/rfc6265#section-5.2.3)
262        //If the first character of the attribute-value string is %x2E ("."):
263        //
264        //Let cookie-domain be the attribute-value without the leading %x2E
265        //(".") character.
266        assert_eq!(
267            CookieDomain::Suffix(String::from(".")),
268            CookieDomain::try_from("..").expect("unable to parse domain")
269        );
270        assert_eq!(
271            CookieDomain::Suffix(String::from("example.com")),
272            CookieDomain::try_from("example.com").expect("unable to parse domain")
273        );
274        assert_eq!(
275            CookieDomain::Suffix(String::from("example.com")),
276            CookieDomain::try_from(".example.com").expect("unable to parse domain")
277        );
278        assert_eq!(
279            CookieDomain::Suffix(String::from(".example.com")),
280            CookieDomain::try_from("..example.com").expect("unable to parse domain")
281        );
282    }
283
284    #[test]
285    fn from_raw_cookie() {
286        fn raw_cookie(s: &str) -> RawCookie<'_> {
287            RawCookie::parse(s).unwrap()
288        }
289        assert_eq!(
290            CookieDomain::NotPresent,
291            CookieDomain::try_from(&raw_cookie("cookie=value")).expect("unable to parse domain")
292        );
293        // cookie::Cookie handles this (cookie.domain == None)
294        assert_eq!(
295            CookieDomain::NotPresent,
296            CookieDomain::try_from(&raw_cookie("cookie=value; Domain="))
297                .expect("unable to parse domain")
298        );
299        // cookie::Cookie does not handle this (empty after stripping leading dot)
300        assert_eq!(
301            CookieDomain::Empty,
302            CookieDomain::try_from(&raw_cookie("cookie=value; Domain=."))
303                .expect("unable to parse domain")
304        );
305        assert_eq!(
306            CookieDomain::Suffix(String::from("example.com")),
307            CookieDomain::try_from(&raw_cookie("cookie=value; Domain=.example.com"))
308                .expect("unable to parse domain")
309        );
310        assert_eq!(
311            CookieDomain::Suffix(String::from("example.com")),
312            CookieDomain::try_from(&raw_cookie("cookie=value; Domain=example.com"))
313                .expect("unable to parse domain")
314        );
315    }
316
317    #[test]
318    fn matches_suffix() {
319        {
320            let suffix = CookieDomain::try_from("example.com").expect("unable to parse domain");
321            variants(true, &suffix, "http://example.com"); //  exact match
322            variants(true, &suffix, "http://foo.example.com"); //  suffix match
323            variants(false, &suffix, "http://example.org"); //  no match
324            variants(false, &suffix, "http://xample.com"); //  request is the suffix, no match
325            variants(false, &suffix, "http://fooexample.com"); //  suffix, but no "." b/w foo and example, no match
326        }
327
328        {
329            // strip leading dot
330            let suffix = CookieDomain::try_from(".example.com").expect("unable to parse domain");
331            variants(true, &suffix, "http://example.com");
332            variants(true, &suffix, "http://foo.example.com");
333            variants(false, &suffix, "http://example.org");
334            variants(false, &suffix, "http://xample.com");
335            variants(false, &suffix, "http://fooexample.com");
336        }
337
338        {
339            // only first leading dot is stripped
340            let suffix = CookieDomain::try_from("..example.com").expect("unable to parse domain");
341            variants(true, &suffix, "http://.example.com");
342            variants(true, &suffix, "http://foo..example.com");
343            variants(false, &suffix, "http://example.com");
344            variants(false, &suffix, "http://foo.example.com");
345            variants(false, &suffix, "http://example.org");
346            variants(false, &suffix, "http://xample.com");
347            variants(false, &suffix, "http://fooexample.com");
348        }
349
350        {
351            // an exact string match, although an IP is specified
352            let suffix = CookieDomain::try_from("127.0.0.1").expect("unable to parse Ipv4");
353            variants(true, &suffix, "http://127.0.0.1");
354        }
355
356        {
357            // an exact string match, although an IP is specified
358            let suffix = CookieDomain::try_from("[::1]").expect("unable to parse Ipv6");
359            variants(true, &suffix, "http://[::1]");
360        }
361
362        {
363            // non-identical suffix match only works for host names (i.e. not IPs)
364            let suffix = CookieDomain::try_from("0.0.1").expect("unable to parse Ipv4");
365            variants(false, &suffix, "http://127.0.0.1");
366        }
367    }
368}
369
370#[cfg(all(test, feature = "serde_json"))]
371mod serde_json_tests {
372    use serde_json;
373    use std::convert::TryFrom;
374
375    use crate::cookie_domain::CookieDomain;
376    use crate::utils::test::*;
377
378    fn encode_decode(cd: &CookieDomain, exp_json: &str) {
379        let encoded = serde_json::to_string(cd).unwrap();
380        assert!(
381            exp_json == encoded,
382            "expected: '{}'\n encoded: '{}'",
383            exp_json,
384            encoded
385        );
386        let decoded: CookieDomain = serde_json::from_str(&encoded).unwrap();
387        assert!(
388            *cd == decoded,
389            "expected: '{:?}'\n decoded: '{:?}'",
390            cd,
391            decoded
392        );
393    }
394
395    #[test]
396    fn serde() {
397        let url = url("http://example.com");
398        encode_decode(
399            &CookieDomain::host_only(&url).expect("cannot parse domain"),
400            "{\"HostOnly\":\"example.com\"}",
401        );
402        encode_decode(
403            &CookieDomain::try_from(".example.com").expect("cannot parse domain"),
404            "{\"Suffix\":\"example.com\"}",
405        );
406        encode_decode(&CookieDomain::NotPresent, "\"NotPresent\"");
407        encode_decode(&CookieDomain::Empty, "\"Empty\"");
408    }
409}