Skip to main content

cookie_store/
cookie_domain.rs

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