Repository: falconry/falcon

Issue:
Multiple cookies with same name in request returns only one
This looks like it could be relatively simple fix, but it would involve changing the cookie API a bit.

If my browser has two cookies set using the same name - let's call this `_csrf` for example - they are both sent to the server. This is the string returned by `req.get_header("Cookie")`:

```
_csrf=2HBpry7fn6OBCIdS2SnFI-jCdQcq3bt0m03gWgCtjRA; _csrf=Oq_o_Lo_dhhDXqZHQ95NhBp8Nvzntx1FUG-2ryAi6nA
```

However, only one of these is made available in `req.cookies`.

It would be preferable to have a method of getting both of these cookies somehow.


Discussion:
We could potentially add a new method, `get_cookie_values()` to handle this case. How else might this be designed? Happy to take suggestions.
Thanks for submitting this Gareth, I think this is a helpful request and I appreciate you taking the time to submit it as an issue.

I don't think this is something that should be supported as I think it breaks that key-value nature of HTTP Cookies. While the [HTTP cookie spec](https://tools.ietf.org/html/rfc6265#section-5.4) does not prohibit it explicitly, it does use the term `set` which, in a mathematical sense are distinct or unique objects.  By adding support for multiple values for a given key, we are no longer working with a set- this would cause deviation from the spec and I do not believe is desirable.

As you have mentioned, you can get the raw value for the `Cookie` header by accessing `req.headers` directly.

To expand on this a bit, by default Werkzueg (the wsgi layer for Flask) uses `MultiDict`'s base class https://github.com/pallets/werkzeug/blob/e378a8f83d7549904abd7ae5b7ac53491a3c76cf/werkzeug/http.py#L997 which is interesting because the feature is there to support the functionality you are requesting (`MultiDict`) however Flask does not use it. 

Here is an example flask app which generates the same result as Falcon (the last value in the cookie header for that key).

```python3
import flask
app = flask.Flask(__name__)

@app.route('/')
def index():
    print(flask.request.cookies)
    return '', 200

if __name__ == '__main__':
    app.run()
```

When I run this HTTP request:

```http
GET / HTTP/1.1
Cookie: _csrf=2HBpry7fn6OBCIdS2SnFI-jCdQcq3bt0m03gWgCtjRA; _csrf=Oq_o_Lo_dhhDXqZHQ95NhBp8Nvzntx1FUG-2ryAi6nA
Host: localhost:5000
Connection: close
User-Agent: Paw/3.1.5 (Macintosh; OS X/10.13.4) GCDHTTPRequest
```

Flask prints `{'_csrf': 'Oq_o_Lo_dhhDXqZHQ95NhBp8Nvzntx1FUG-2ryAi6nA'}` which means it is taking the second value, that seems to be more appropriate behavior and what Falcon is using.
What's the consensus on this issue? I agree that it goes against best practice but it could still be helpful for some use cases. I could work on it this weekend but I'll defer to you guys on whether or not it should be adopted. 
This is an interesting part of [RFC 6265 - 4.2.2](https://tools.ietf.org/html/rfc6265#section-4.2.2):
> Although cookies are serialized linearly in the Cookie header,
   servers SHOULD NOT rely upon the serialization order.  In particular,
   if the Cookie header contains two cookies with the same name (e.g.,
   that were set with different Path or Domain attributes), servers
   SHOULD NOT rely upon the order in which these cookies appear in the
   header.

However, it does not seem to mention which cookie should take precedence when two are set with the same name. The one with a more specific path? I think in the case given above there is no obvious choice, and therefore it is incorrect usage.

I think the answer is still that ONE has to take precedence. It seems falcon chooses based on whatever was added last, which based on the above spec could be interpreted as incorrect; although it does say SHOULD NOT. 😉 

Some solutions to the above answer:

- There are two csrf tokens, rename them to be more specific `csrf_form1` `csrf_form2`
- Concatenate the tokens somehow and encode them in Base64. This is a note from the spec about servers, but could apply to the above issue as well I think.
    > To maximize compatibility with user agents, servers that wish to
       store arbitrary data in a cookie-value SHOULD encode that data, for
       example, using Base64 [RFC4648].

In summary, if we should change anything I think it should be how we determine cookie precedence.


Resolving patch:
diff --git a/falcon/request.py b/falcon/request.py
--- a/falcon/request.py
+++ b/falcon/request.py
@@ -27,11 +27,6 @@
 from falcon.util.uri import parse_host, parse_query_string
 from falcon.vendor import mimeparse
 
-# NOTE(tbug): In some cases, compat.http_cookies is not a module
-# but a dict-like structure. This fixes that issue.
-# See issue https://github.com/falconry/falcon/issues/556
-SimpleCookie = compat.http_cookies.SimpleCookie
-
 DEFAULT_ERROR_LOG_FORMAT = (u'{0:%Y-%m-%d %H:%M:%S} [FALCON] [ERROR]'
                             u' {1} {2}{3} => ')
 
@@ -256,8 +251,12 @@ class Request(object):
         client_accepts_xml (bool): ``True`` if the Accept header indicates that
             the client is willing to receive XML, otherwise ``False``.
         cookies (dict):
-            A dict of name/value cookie pairs. (See also:
-            :ref:`Getting Cookies <getting-cookies>`)
+            A dict of name/value cookie pairs. The returned object should be
+            treated as read-only to avoid unintended side-effects.
+            If a cookie appears more than once in the request, only the first
+            value encountered will be made available here.
+
+            See also: :meth:`~get_cookie_values`
         content_type (str): Value of the Content-Type header, or ``None`` if
             the header is missing.
         content_length (int): Value of the Content-Length header converted
@@ -394,7 +393,6 @@ class Request(object):
         '_cached_prefix',
         '_cached_relative_uri',
         '_cached_uri',
-        '_cookies',
         '_params',
         '_wsgierrors',
         'content_type',
@@ -409,6 +407,9 @@ class Request(object):
         '_media',
     )
 
+    _cookies = None
+    _cookies_collapsed = None
+
     # Child classes may override this
     context_type = type('RequestContext', (dict,), {})
 
@@ -456,8 +457,6 @@ def __init__(self, env, options=None):
             else:
                 self._params = {}
 
-        self._cookies = None
-
         self._cached_access_route = None
         self._cached_forwarded = None
         self._cached_forwarded_prefix = None
@@ -825,24 +824,17 @@ def params(self):
 
     @property
     def cookies(self):
-        if self._cookies is None:
-            # NOTE(tbug): We might want to look into parsing
-            # cookies ourselves. The SimpleCookie is doing a
-            # lot if stuff only required to SEND cookies.
-            cookie_header = self.get_header('Cookie', default='')
-            parser = SimpleCookie()
-            for cookie_part in cookie_header.split('; '):
-                try:
-                    parser.load(cookie_part)
-                except compat.http_cookies.CookieError:
-                    pass
-            cookies = {}
-            for morsel in parser.values():
-                cookies[morsel.key] = morsel.value
+        if self._cookies_collapsed is None:
+            if self._cookies is None:
+                header_value = self.get_header('Cookie')
+                if header_value:
+                    self._cookies = helpers.parse_cookie_header(header_value)
+                else:
+                    self._cookies = {}
 
-            self._cookies = cookies
+            self._cookies_collapsed = {n: v[0] for n, v in self._cookies.items()}
 
-        return self._cookies
+        return self._cookies_collapsed
 
     @property
     def access_route(self):
@@ -1077,6 +1069,37 @@ def get_header_as_datetime(self, header, required=False, obs_date=False):
                    'Section 7.1.1.1')
             raise errors.HTTPInvalidHeader(msg, header)
 
+    def get_cookie_values(self, name):
+        """Return all values provided in the Cookie header for the named cookie.
+
+        (See also: :ref:`Getting Cookies <getting-cookies>`)
+
+        Args:
+            name (str): Cookie name, case-sensitive.
+
+        Returns:
+            list: Ordered list of all values specified in the Cookie header for
+            the named cookie, or ``None`` if the cookie was not included in
+            the request. If the cookie is specified more than once in the
+            header, the returned list of values will preserve the ordering of
+            the individual ``cookie-pair``'s in the header.
+        """
+
+        if self._cookies is None:
+            # PERF(kgriffs): While this code isn't exactly DRY (the same code
+            # is duplicated by the cookies property) it does make things a bit
+            # more performant by removing the extra function call that would
+            # be required to factor this out. If we ever have to do this in a
+            # *third* place, we would probably want to factor it out at that
+            # point.
+            header_value = self.get_header('Cookie')
+            if header_value:
+                self._cookies = helpers.parse_cookie_header(header_value)
+            else:
+                self._cookies = {}
+
+        return self._cookies.get(name)
+
     def get_param(self, name, required=False, store=None, default=None):
         """Return the raw value of a query string parameter as a string.
 
diff --git a/falcon/request_helpers.py b/falcon/request_helpers.py
--- a/falcon/request_helpers.py
+++ b/falcon/request_helpers.py
@@ -15,6 +15,78 @@
 """Utilities for the Request class."""
 
 import io
+import re
+
+from falcon.util.compat import http_cookies
+
+# https://tools.ietf.org/html/rfc6265#section-4.1.1
+#
+# NOTE(kgriffs): Fortunately we don't have to worry about code points in
+#   header strings outside the range 0x0000 - 0x00FF per PEP 3333
+#   (see also: https://www.python.org/dev/peps/pep-3333/#unicode-issues)
+#
+_COOKIE_NAME_RESERVED_CHARS = re.compile('[\x00-\x1F\x7F-\xFF()<>@,;:\\\\"/[\\]?={} \x09]')
+
+
+def parse_cookie_header(header_value):
+    """Parse a Cookie header value into a dict of named values.
+
+    (See also: RFC 6265, Section 5.4)
+
+    Args:
+        header_value (str): Value of a Cookie header
+
+    Returns:
+        dict: Map of cookie names to a list of all cookie values found in the
+        header for that name. If a cookie is specified more than once in the
+        header, the order of the values will be preserved.
+    """
+
+    # See also:
+    #
+    #   https://tools.ietf.org/html/rfc6265#section-5.4
+    #   https://tools.ietf.org/html/rfc6265#section-4.1.1
+    #
+
+    cookies = {}
+
+    for token in header_value.split(';'):
+        name, __, value = token.partition('=')
+
+        # NOTE(kgriffs): RFC6265 is more strict about whitespace, but we
+        # are more lenient here to better handle old user agents and to
+        # mirror Python's standard library cookie parsing behavior
+        name = name.strip()
+        value = value.strip()
+
+        # NOTE(kgriffs): Skip malformed cookie-pair
+        if not name:
+            continue
+
+        # NOTE(kgriffs): Skip cookies with invalid names
+        if _COOKIE_NAME_RESERVED_CHARS.search(name):
+            continue
+
+        # NOTE(kgriffs): To maximize compatibility, we mimic the support in the
+        # standard library for escaped characters within a double-quoted
+        # cookie value according to the obsolete RFC 2109. However, we do not
+        # expect to see this encoding used much in practice, since Base64 is
+        # the current de-facto standard, as recommended by RFC 6265.
+        #
+        # PERF(kgriffs): These checks have been hoisted from within _unquote()
+        # to avoid the extra function call in the majority of the cases when it
+        # is not needed.
+        if len(value) > 2 and value[0] == '"' and value[-1] == '"':
+            value = http_cookies._unquote(value)
+
+        # PERF(kgriffs): This is slightly more performant as
+        # compared to using dict.setdefault()
+        if name in cookies:
+            cookies[name].append(value)
+        else:
+            cookies[name] = [value]
+
+    return cookies
 
 
 def header_property(wsgi_name):


Test patch:
diff --git a/tests/test_cookies.py b/tests/test_cookies.py
--- a/tests/test_cookies.py
+++ b/tests/test_cookies.py
@@ -241,7 +241,9 @@ def test_request_cookie_parsing():
             'Cookie',
             """
             logged_in=no;_gh_sess=eyJzZXXzaW9uX2lkIjoiN2;
-            tz=Europe/Berlin; _ga=GA1.2.332347814.1422308165;
+            tz=Europe/Berlin; _ga =GA1.2.332347814.1422308165;
+            tz2=Europe/Paris ; _ga2="line1\\012line2";
+            tz3=Europe/Madrid ;_ga3= GA3.2.332347814.1422308165;
             _gat=1;
             _octo=GH1.1.201722077.1422308165
             """
@@ -251,39 +253,75 @@ def test_request_cookie_parsing():
     environ = testing.create_environ(headers=headers)
     req = falcon.Request(environ)
 
-    assert req.cookies['logged_in'] == 'no'
-    assert req.cookies['tz'] == 'Europe/Berlin'
-    assert req.cookies['_octo'] == 'GH1.1.201722077.1422308165'
-
-    assert 'logged_in' in req.cookies
-    assert '_gh_sess' in req.cookies
-    assert 'tz' in req.cookies
-    assert '_ga' in req.cookies
-    assert '_gat' in req.cookies
-    assert '_octo' in req.cookies
+    # NOTE(kgriffs): Test case-sensitivity
+    assert req.get_cookie_values('TZ') is None
+    assert 'TZ' not in req.cookies
+    with pytest.raises(KeyError):
+        req.cookies['TZ']
+
+    for name, value in [
+        ('logged_in', 'no'),
+        ('_gh_sess', 'eyJzZXXzaW9uX2lkIjoiN2'),
+        ('tz', 'Europe/Berlin'),
+        ('tz2', 'Europe/Paris'),
+        ('tz3', 'Europe/Madrid'),
+        ('_ga', 'GA1.2.332347814.1422308165'),
+        ('_ga2', 'line1\nline2'),
+        ('_ga3', 'GA3.2.332347814.1422308165'),
+        ('_gat', '1'),
+        ('_octo', 'GH1.1.201722077.1422308165'),
+    ]:
+        assert name in req.cookies
+        assert req.cookies[name] == value
+        assert req.get_cookie_values(name) == [value]
 
 
 def test_invalid_cookies_are_ignored():
+    vals = [chr(i) for i in range(0x1F)]
+    vals += [chr(i) for i in range(0x7F, 0xFF)]
+    vals += '()<>@,;:\\"/[]?={} \x09'.split()
+
+    for c in vals:
+        headers = [
+            (
+                'Cookie',
+                'good_cookie=foo;bad' + c + 'cookie=bar'
+            ),
+        ]
+
+        environ = testing.create_environ(headers=headers)
+        req = falcon.Request(environ)
+
+        assert req.cookies['good_cookie'] == 'foo'
+        assert 'bad' + c + 'cookie' not in req.cookies
+
+
+def test_duplicate_cookie():
     headers = [
         (
             'Cookie',
-            """
-            good_cookie=foo;
-            bad{cookie=bar
-            """
+            'x=1;bad{cookie=bar; x=2;x=3 ; x=4;'
         ),
     ]
 
     environ = testing.create_environ(headers=headers)
     req = falcon.Request(environ)
 
-    assert req.cookies['good_cookie'] == 'foo'
-    assert 'bad{cookie' not in req.cookies
+    assert req.cookies['x'] == '1'
+    assert req.get_cookie_values('x') == ['1', '2', '3', '4']
 
 
 def test_cookie_header_is_missing():
     environ = testing.create_environ(headers={})
+
+    req = falcon.Request(environ)
+    assert req.cookies == {}
+    assert req.get_cookie_values('x') is None
+
+    # NOTE(kgriffs): Test again with a new object to cover calling in the
+    #   opposite order.
     req = falcon.Request(environ)
+    assert req.get_cookie_values('x') is None
     assert req.cookies == {}