Repository: sanic-org/sanic

Issue:
Cannot delete cookies due to missing `secure` parameter
### Is there an existing issue for this?

- [X] I have searched the existing issues

### Describe the bug

The `delete_cookie` method in `CookieJar` lacks the `secure` parameter, and this parameter in [its internal call to `self.add_cookie`](https://github.com/sanic-org/sanic/blob/39d4ea49761bb2221cd6cb970a41e9099990273f/sanic/cookies/response.py#L408) default to True.

Therefore, attempts to delete a cookie in an insecure (plain HTTP) origin will be always blocked by the browser because it will set the `;Secure` flag:

![image](https://github.com/sanic-org/sanic/assets/6646473/1dade3d9-607f-429b-b31b-c612e6de5145)

### Code snippet

```python
from sanic import Sanic
from sanic.response import text, redirect

app = Sanic("test")
app.config.AUTO_EXTEND = False

@app.get("/")
async def index(request):
    return text(f'logged in: {"YES" if request.cookies.get("session", None) else "NO"}')

@app.get("/login")
async def login(request):
    res = redirect('/')
    res.cookies.add_cookie('session', 'foobar', secure=False)
    return res
    
@app.get("/logout")
async def logout(request):
    res = redirect('/')
    res.cookies.delete_cookie('session')
    return res

if __name__ == '__main__':
    app.run(host='0.0.0.0', port=4321, debug=True)
```

1. Visit `http://YOUR_IP:4321/` and the page says `logged in: NO`
2. Visit `http://YOUR_IP:4321/login` and the page says `logged in: YES`
3. Visit `http://YOUR_IP:4321/logout` and the page still says `logged in: YES`

Note: it is important to use the real IP address (instead of 127.0.0.1) to reproduce this bug, since 127.0.0.1 is a secure origin.

### Expected Behavior

Sanic should add an optional `secure` parameter to `delete_cookie` method, so we can write something like `res.cookies.delete_cookie('session', secure=False)`.

Then the page should say `logged in: NO` after visiting `/logout`.

### How do you run Sanic?

As a script (`app.run` or `Sanic.serve`)

### Operating System

Linux

### Sanic Version

v23.12.1

### Additional context

_No response_

Discussion:

Patch:
diff --git a/sanic/cookies/response.py b/sanic/cookies/response.py
index a0e18d19..e84a24d8 100644
--- a/sanic/cookies/response.py
+++ b/sanic/cookies/response.py
@@ -363,6 +363,7 @@ class CookieJar(dict):
         *,
         path: str = "/",
         domain: Optional[str] = None,
+        secure: bool = True,
         host_prefix: bool = False,
         secure_prefix: bool = False,
     ) -> None:
@@ -381,6 +382,8 @@ class CookieJar(dict):
         :type path: Optional[str], optional
         :param domain: Domain of the cookie, defaults to None
         :type domain: Optional[str], optional
+        :param secure: Whether to delete a secure cookie. Defaults to True.
+        :param secure: bool
         :param host_prefix: Whether to add __Host- as a prefix to the key.
             This requires that path="/", domain=None, and secure=True,
             defaults to False
@@ -389,8 +392,18 @@ class CookieJar(dict):
             This requires that secure=True, defaults to False
         :type secure_prefix: bool
         """
-        # remove it from header
+        if host_prefix and not (secure and path == "/" and domain is None):
+            raise ServerError(
+                "Cannot set host_prefix on a cookie without "
+                "path='/', domain=None, and secure=True"
+            )
+        if secure_prefix and not secure:
+            raise ServerError(
+                "Cannot set secure_prefix on a cookie without secure=True"
+            )
+
         cookies: List[Cookie] = self.headers.popall(self.HEADER_KEY, [])
+        existing_cookie = None
         for cookie in cookies:
             if (
                 cookie.key != Cookie.make_key(key, host_prefix, secure_prefix)
@@ -398,23 +411,42 @@ class CookieJar(dict):
                 or cookie.domain != domain
             ):
                 self.headers.add(self.HEADER_KEY, cookie)
-
+            elif existing_cookie is None:
+                existing_cookie = cookie
         # This should be removed in v24.3
         try:
             super().__delitem__(key)
         except KeyError:
             ...
 
-        self.add_cookie(
-            key=key,
-            value="",
-            path=path,
-            domain=domain,
-            max_age=0,
-            samesite=None,
-            host_prefix=host_prefix,
-            secure_prefix=secure_prefix,
-        )
+        if existing_cookie is not None:
+            # Use all the same values as the cookie to be deleted
+            # except value="" and max_age=0
+            self.add_cookie(
+                key=key,
+                value="",
+                path=existing_cookie.path,
+                domain=existing_cookie.domain,
+                secure=existing_cookie.secure,
+                max_age=0,
+                httponly=existing_cookie.httponly,
+                partitioned=existing_cookie.partitioned,
+                samesite=existing_cookie.samesite,
+                host_prefix=host_prefix,
+                secure_prefix=secure_prefix,
+            )
+        else:
+            self.add_cookie(
+                key=key,
+                value="",
+                path=path,
+                domain=domain,
+                secure=secure,
+                max_age=0,
+                samesite=None,
+                host_prefix=host_prefix,
+                secure_prefix=secure_prefix,
+            )
 
 
 # In v24.3, we should remove this as being a subclass of dict


Test patch:
diff --git a/tests/test_cookies.py b/tests/test_cookies.py
index 599a24ef..18c01a3a 100644
--- a/tests/test_cookies.py
+++ b/tests/test_cookies.py
@@ -380,6 +380,67 @@ def test_cookie_jar_delete_cookie_encode():
     ]
 
 
+def test_cookie_jar_delete_nonsecure_cookie():
+    headers = Header()
+    jar = CookieJar(headers)
+    jar.delete_cookie("foo", domain="example.com", secure=False)
+
+    encoded = [cookie.encode("ascii") for cookie in jar.cookies]
+    assert encoded == [
+        b'foo=""; Path=/; Domain=example.com; Max-Age=0',
+    ]
+
+
+def test_cookie_jar_delete_existing_cookie():
+    headers = Header()
+    jar = CookieJar(headers)
+    jar.add_cookie(
+        "foo", "test", secure=True, domain="example.com", samesite="Strict"
+    )
+    jar.delete_cookie("foo", domain="example.com", secure=True)
+
+    encoded = [cookie.encode("ascii") for cookie in jar.cookies]
+    # deletion cookie contains samesite=Strict as was in original cookie
+    assert encoded == [
+        b'foo=""; Path=/; Domain=example.com; Max-Age=0; SameSite=Strict; Secure',
+    ]
+
+
+def test_cookie_jar_delete_existing_nonsecure_cookie():
+    headers = Header()
+    jar = CookieJar(headers)
+    jar.add_cookie(
+        "foo", "test", secure=False, domain="example.com", samesite="Strict"
+    )
+    jar.delete_cookie("foo", domain="example.com", secure=False)
+
+    encoded = [cookie.encode("ascii") for cookie in jar.cookies]
+    # deletion cookie contains samesite=Strict as was in original cookie
+    assert encoded == [
+        b'foo=""; Path=/; Domain=example.com; Max-Age=0; SameSite=Strict',
+    ]
+
+
+def test_cookie_jar_delete_existing_nonsecure_cookie_bad_prefix():
+    headers = Header()
+    jar = CookieJar(headers)
+    jar.add_cookie(
+        "foo", "test", secure=False, domain="example.com", samesite="Strict"
+    )
+    message = (
+        "Cannot set host_prefix on a cookie without "
+        "path='/', domain=None, and secure=True"
+    )
+    with pytest.raises(ServerError, match=message):
+        jar.delete_cookie(
+            "foo",
+            domain="example.com",
+            secure=False,
+            secure_prefix=True,
+            host_prefix=True,
+        )
+
+
 def test_cookie_jar_old_school_delete_encode():
     headers = Header()
     jar = CookieJar(headers)