Repository: pallets/werkzeug

Issue:
`set_cookie` will no longer set path by default
It seems that since version 2.3.0 of Werkzeug the behaviour of `set_cookie` has changed. The documentation of the method says that by default the cookie will span the whole domain. In older versions this was true: if the parameter `path` was not set it defaulted to `"/"`. Now it defaults to `None`, which leads to `Path` being omitted in the `Set-Cookie` part of the header in the response sent to the client. The browser (tested with Firefox) seems then to just take the path from the request URL.

```
# return from an endpoint like /cookie/set
response.set_cookie('test_cookie', cookie_payload, expires=exp_in_thirty_minutes, secure=True,
                        samesite='Strict')
# --> cookie for client will have path set to /cookie
```

Expected is that `Path` of the cookie will be set to "/" according to the documentation of `set_cookie`.

Environment:

- Python version: 3.10
- Werkzeug version: 2.3.1



Discussion:
That's correct. If the path is not given, browsers do not set a limit on the path, therefore it does not need to be sent by default. I can't reproduce your result, Firefox and Chrome do not use the current path, they use `/`.
I have here an minimal example:

```
from werkzeug.wrappers import Request, Response


def set_cookie(request):
    response = Response("Cookie set!")
    response.set_cookie("my_cookie", "cookie_value")
    return response


def application(environ, start_response):
    request = Request(environ)
    response = None

    if request.path == '/cookie/set':
        response = set_cookie(request)
    else:
        response = Response("Page not found", status=404)

    return response(environ, start_response)


if __name__ == '__main__':
    from werkzeug.serving import run_simple

    run_simple('localhost', 5000, application)
```

after visiting `http://localhost:5000/cookie/set` I have the following cookie:

![image](https://user-images.githubusercontent.com/17177211/235173132-86c49c0b-e54d-4de1-a5cb-3b398bb05bd8.png)

If this behavior differs it must be some Browser differences. 
But in my understanding it seems to match the behavior specified here [RFC 6265 ](https://datatracker.ietf.org/doc/html/rfc6265#section-5.2.4) using the definition of `default-path` from [here](https://datatracker.ietf.org/doc/html/rfc6265#section-5.1.4).
Again, I can't reproduce your result. Firefox and Chrome both use `/` if the path is not given. I used your example, navigated to `http://localhost:5000/cookie/set`, and the cookie was set with `/` for the path.
OK, that is confusing. I just tried it with Chrome as well (running the minimal example from my last response) and I get the same behavior:

![image](https://user-images.githubusercontent.com/17177211/235176277-63da0d00-2ffe-485d-b6d0-bb81d97dd052.png)

Output from `pip freeze`:

```
MarkupSafe==2.1.2
Werkzeug==2.3.1
```
with Python 3.10.10

Hmm, I originally removed it because it looked like the default was `/` anyway, which saved 8 bytes. But I missed the RFC's description of `default-path`. Maybe my browser's behavior is due to different privacy settings or something? I can add `Path=/` back in.

Resolving patch:
diff --git a/src/werkzeug/http.py b/src/werkzeug/http.py
--- a/src/werkzeug/http.py
+++ b/src/werkzeug/http.py
@@ -1388,7 +1388,7 @@ def dump_cookie(
 
     .. _`cookie`: http://browsercookielimits.squawky.net/
 
-    .. versionchanged:: 2.3.2
+    .. versionchanged:: 2.3.3
         The ``path`` parameter is ``/`` by default.
 
     .. versionchanged:: 2.3.1
diff --git a/src/werkzeug/sansio/response.py b/src/werkzeug/sansio/response.py
--- a/src/werkzeug/sansio/response.py
+++ b/src/werkzeug/sansio/response.py
@@ -225,7 +225,7 @@ def set_cookie(
         value: str = "",
         max_age: timedelta | int | None = None,
         expires: str | datetime | int | float | None = None,
-        path: str | None = None,
+        path: str | None = "/",
         domain: str | None = None,
         secure: bool = False,
         httponly: bool = False,
@@ -276,7 +276,7 @@ def set_cookie(
     def delete_cookie(
         self,
         key: str,
-        path: str | None = None,
+        path: str | None = "/",
         domain: str | None = None,
         secure: bool = False,
         httponly: bool = False,


Test patch:
diff --git a/src/werkzeug/test.py b/src/werkzeug/test.py
--- a/src/werkzeug/test.py
+++ b/src/werkzeug/test.py
@@ -948,7 +948,7 @@ def set_cookie(
             value = args[0]
 
         cookie = Cookie._from_response_header(
-            domain, dump_cookie(key, value, domain=domain, path=path, **kwargs)
+            domain, "/", dump_cookie(key, value, domain=domain, path=path, **kwargs)
         )
         cookie.origin_only = origin_only
 
@@ -1037,7 +1037,7 @@ def _add_cookies_to_wsgi(self, environ: WSGIEnvironment) -> None:
             environ.pop("HTTP_COOKIE", None)
 
     def _update_cookies_from_response(
-        self, server_name: str, headers: list[str]
+        self, server_name: str, path: str, headers: list[str]
     ) -> None:
         """If cookies are enabled, update the stored cookies from any ``Set-Cookie``
         headers in the response.
@@ -1050,7 +1050,7 @@ def _update_cookies_from_response(
             return
 
         for header in headers:
-            cookie = Cookie._from_response_header(server_name, header)
+            cookie = Cookie._from_response_header(server_name, path, header)
 
             if cookie._should_delete:
                 self._cookies.pop(cookie._storage_key, None)
@@ -1066,8 +1066,10 @@ def run_wsgi_app(
         """
         self._add_cookies_to_wsgi(environ)
         rv = run_wsgi_app(self.application, environ, buffered=buffered)
-        server_name = urlsplit(get_current_url(environ)).hostname or "localhost"
-        self._update_cookies_from_response(server_name, rv[2].getlist("Set-Cookie"))
+        url = urlsplit(get_current_url(environ))
+        self._update_cookies_from_response(
+            url.hostname or "localhost", url.path, rv[2].getlist("Set-Cookie")
+        )
         return rv
 
     def resolve_redirect(
@@ -1506,7 +1508,7 @@ def _to_request_header(self) -> str:
         return f"{self.key}={self.value}"
 
     @classmethod
-    def _from_response_header(cls, server_name: str, header: str) -> te.Self:
+    def _from_response_header(cls, server_name: str, path: str, header: str) -> te.Self:
         header, _, parameters_str = header.partition(";")
         key, _, value = header.partition("=")
         decoded_key, decoded_value = next(parse_cookie(header).items())
@@ -1514,21 +1516,21 @@ def _from_response_header(cls, server_name: str, header: str) -> te.Self:
 
         for item in parameters_str.split(";"):
             k, sep, v = item.partition("=")
-            params[k.strip()] = v.strip() if sep else None
+            params[k.strip().lower()] = v.strip() if sep else None
 
         return cls(
             key=key.strip(),
             value=value.strip(),
             decoded_key=decoded_key,
             decoded_value=decoded_value,
-            expires=parse_date(params.get("Expires")),
-            max_age=int(params["Max-Age"] or 0) if "Max-Age" in params else None,
-            domain=params.get("Domain", server_name) or server_name,
-            origin_only="Domain" not in params,
-            path=params.get("Path", "/") or "/",
-            secure="Secure" in params,
-            http_only="HttpOnly" in params,
-            same_site=params.get("SameSite"),
+            expires=parse_date(params.get("expires")),
+            max_age=int(params["max-age"] or 0) if "max-age" in params else None,
+            domain=params.get("domain") or server_name,
+            origin_only="domain" not in params,
+            path=params.get("path") or path.rpartition("/")[0] or "/",
+            secure="secure" in params,
+            http_only="httponly" in params,
+            same_site=params.get("samesite"),
         )
 
     @property
diff --git a/tests/test_test.py b/tests/test_test.py
--- a/tests/test_test.py
+++ b/tests/test_test.py
@@ -117,6 +117,25 @@ def test_cookie_for_different_path():
     assert response.text == "test=test"
 
 
+def test_cookie_default_path() -> None:
+    """When no path is set for a cookie, the default uses everything up to but not
+    including the first slash.
+    """
+
+    @Request.application
+    def app(request: Request) -> Response:
+        r = Response()
+        r.set_cookie("k", "v", path=None)
+        return r
+
+    c = Client(app)
+    c.get("/nested/leaf")
+    assert c.get_cookie("k") is None
+    assert c.get_cookie("k", path="/nested") is not None
+    c.get("/nested/dir/")
+    assert c.get_cookie("k", path="/nested/dir") is not None
+
+
 def test_environ_builder_basics():
     b = EnvironBuilder()
     assert b.content_type is None
diff --git a/tests/test_wrappers.py b/tests/test_wrappers.py
--- a/tests/test_wrappers.py
+++ b/tests/test_wrappers.py
@@ -271,7 +271,7 @@ def test_base_response():
         ("Content-Type", "text/plain; charset=utf-8"),
         (
             "Set-Cookie",
-            "foo=; Expires=Thu, 01 Jan 1970 00:00:00 GMT; Max-Age=0",
+            "foo=; Expires=Thu, 01 Jan 1970 00:00:00 GMT; Max-Age=0; Path=/",
         ),
     ]