Repository: sanic-org/sanic

Issue:
Cookie totally breaks if the client sets a bare cookie
### Is there an existing issue for this?

- [X] I have searched the existing issues

### Describe the bug

A cookie may be not in the `key=value` format. For example. if the JS code runs `document.cookie = "bad"`, it becomes:

![image](https://github.com/sanic-org/sanic/assets/6646473/c9a1f53c-62bf-4516-9003-49fed08773dc)

I don't know how to call it. I will use the term "bare cookie" in this report. In the following requests with a bare cookie, the Cookie HTTP header becomes: `Cookie: key=value; bad`

![image](https://github.com/sanic-org/sanic/assets/6646473/536bdf38-61ad-4f31-bb78-6c7844433298)

It seems that Sanic cannot parse the header with bare cookies, and will throw all cookies (including the legimit `key=value` pair) away. See the code snippet below.

### Code snippet

```python
from sanic import Sanic
from sanic.response import html, text

app = Sanic("test")
app.config.AUTO_EXTEND = False

@app.get("/")
async def route1(request):
    return html('<script>document.cookie="key=value"; document.cookie="bad"; location.href="/fire";</script>')
    
@app.get("/fire")
async def route2(request):
    return text(f'''
        headers = {request.headers.get("Cookie")}
        key = {request.cookies.get("key", "none")}
    ''')

if __name__ == '__main__':
    app.run(port=4321, debug=True)
```

Then visit `http://127.0.0.1:4321/` in Chrome. The page shows:

```
headers = key=value; bad
key = none
```

### Expected Behavior

The page should show:

```
headers = key=value; bad
key = value
```

### How do you run Sanic?

As a script (`app.run` or `Sanic.serve`)

### Operating System

Windows

### Sanic Version

22.12.0

### Additional context

I am using the latest stable Chrome (117.0.5938.150) to reproduce this.





Discussion:
Are you aware if this cookie format is allowed by any specs, or if it simply a quirk that the browser can send it like that? Browsers otherwise do some formatting on document.cookie and don't simply pass the string.

Malformed cookie headers could be used for exploiting parsers which understand them in different ways, potentially altering content of another cookie, so we try to make Sanic err on the side of rejection all of it rather than potentially return manipulated values, although it is noted that setting a cookie to prevent other cookies being parsed can also be exploited.

Should we simply skip the "bare cookie" and still try to read the rest, or store it as a `""` named cookie value? Needs investigation. Any real world implications to this?

See https://github.com/httpwg/http-extensions/issues/159 and https://github.com/httpwg/http-extensions/pull/1018. It seems that `document.cookie = "foo"` is considered as a Cookie of a null key and a `foo` value.

The impact to my website is that if a frontend script accidentially sets things like `document.cookie = "x"`, the user will never be able to log in, because Sanic will refuse to read any other values in the cookie.

Resolving patch:
diff --git a/sanic/cookies/request.py b/sanic/cookies/request.py
--- a/sanic/cookies/request.py
+++ b/sanic/cookies/request.py
@@ -73,12 +73,17 @@ def parse_cookie(raw: str) -> Dict[str, List[str]]:
     cookies: Dict[str, List[str]] = {}
 
     for token in raw.split(";"):
-        name, __, value = token.partition("=")
+        name, sep, value = token.partition("=")
         name = name.strip()
         value = value.strip()
 
-        if not name:
-            continue
+        # Support cookies =value or plain value with no name
+        # https://github.com/httpwg/http-extensions/issues/159
+        if not sep:
+            if not name:
+                # Empty value like ;; or a cookie header with no value
+                continue
+            name, value = "", name
 
         if COOKIE_NAME_RESERVED_CHARS.search(name):  # no cov
             continue
diff --git a/sanic/models/protocol_types.py b/sanic/models/protocol_types.py
--- a/sanic/models/protocol_types.py
+++ b/sanic/models/protocol_types.py
@@ -3,7 +3,7 @@
 import sys
 
 from asyncio import BaseTransport
-from typing import TYPE_CHECKING, Any, AnyStr, Optional
+from typing import TYPE_CHECKING, Any, Optional, Union
 
 
 if TYPE_CHECKING:
@@ -19,10 +19,10 @@
     from typing import Protocol
 
     class HTMLProtocol(Protocol):
-        def __html__(self) -> AnyStr:
+        def __html__(self) -> Union[str, bytes]:
             ...
 
-        def _repr_html_(self) -> AnyStr:
+        def _repr_html_(self) -> Union[str, bytes]:
             ...
 
     class Range(Protocol):


Test patch:
diff --git a/tests/test_cookies.py b/tests/test_cookies.py
--- a/tests/test_cookies.py
+++ b/tests/test_cookies.py
@@ -7,12 +7,28 @@
 from sanic import Request, Sanic
 from sanic.compat import Header
 from sanic.cookies import Cookie, CookieJar
-from sanic.cookies.request import CookieRequestParameters
+from sanic.cookies.request import CookieRequestParameters, parse_cookie
 from sanic.exceptions import ServerError
 from sanic.response import text
 from sanic.response.convenience import json
 
 
+def test_request_cookies():
+    cdict = parse_cookie("foo=one; foo=two; abc = xyz;;bare;=bare2")
+    assert cdict == {
+        "foo": ["one", "two"],
+        "abc": ["xyz"],
+        "": ["bare", "bare2"],
+    }
+    c = CookieRequestParameters(cdict)
+    assert c.getlist("foo") == ["one", "two"]
+    assert c.getlist("abc") == ["xyz"]
+    assert c.getlist("") == ["bare", "bare2"]
+    assert (
+        c.getlist("bare") == None
+    )  # [] might be sensible but we got None for now
+
+
 # ------------------------------------------------------------ #
 #  GET
 # ------------------------------------------------------------ #
diff --git a/tests/test_graceful_shutdown.py b/tests/test_graceful_shutdown.py
--- a/tests/test_graceful_shutdown.py
+++ b/tests/test_graceful_shutdown.py
@@ -1,6 +1,8 @@
 import asyncio
 import logging
 
+import pytest
+
 from pytest import LogCaptureFixture
 
 from sanic.response import empty
@@ -9,6 +11,7 @@
 PORT = 42101
 
 
+@pytest.mark.xfail(reason="This test runs fine locally, but fails on CI")
 def test_no_exceptions_when_cancel_pending_request(
     app, caplog: LogCaptureFixture
 ):