Skip to content

Commit

Permalink
feat(Response): support Partitioned cookie attribute
Browse files Browse the repository at this point in the history
Allow to set the Partitioned attribute in cookies.
Default is not setting it.

closes #2213
  • Loading branch information
M-Mueller committed Jul 13, 2024
1 parent c345420 commit bd1fbe7
Show file tree
Hide file tree
Showing 5 changed files with 73 additions and 1 deletion.
20 changes: 20 additions & 0 deletions docs/api/cookies.rst
Original file line number Diff line number Diff line change
Expand Up @@ -168,3 +168,23 @@ default, although this may change in a future release.
When unsetting a cookie, :py:meth:`~falcon.Response.unset_cookie`,
the default `SameSite` setting of the unset cookie is ``'Lax'``, but can be changed
by setting the 'samesite' kwarg.

The Partitioned Attribute
~~~~~~~~~~~~~~~~~~~~~~~~~

Starting from Q1 2024, Google Chrome will start to `phaseout support for third-party
cookies <https://developers.google.com/privacy-sandbox/3pcd/prepare/prepare-for-phaseout>`_.
If your site is relying on cross-site cookies, it might be necessary to set the
`Partitioned` attribute. `Partitioned` usually requires the `Secure` attribute
to be set, but this is not enforced by Falcon.

Currently, :py:meth:`~falcon.Response.set_cookie` does not set `Partitioned` automatically
depending on other attributes (like `SameSite`), although this may change in a future release.

.. note::

Similar to `SameSite`, the standard ``http.cookies`` module does not
support the `Partitioned` attribute yet and Falcon performs the same
monkey-patch as for `SameSite`.


15 changes: 15 additions & 0 deletions falcon/response.py
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,7 @@ def set_cookie(
secure=None,
http_only=True,
same_site=None,
partitioned=False,
):
"""Set a response cookie.
Expand Down Expand Up @@ -447,6 +448,14 @@ def set_cookie(
(See also: `Same-Site RFC Draft`_)
partitioned (bool): Prevents cookies from being accessed from other
subdomains. With partitioned enabled, a cookie set by
https://3rd-party.example which is embedded inside
https://site-a.example can no longer be accessed by
https://site-b.example. While this attribute is not yet
standardized, it is already used by Chrome.
(See also: `Partitioned RFC Draft`_)
Raises:
KeyError: `name` is not a valid cookie name.
ValueError: `value` is not a valid cookie value.
Expand All @@ -457,6 +466,9 @@ def set_cookie(
.. _Same-Site RFC Draft:
https://tools.ietf.org/html/draft-ietf-httpbis-rfc6265bis-03#section-4.1.2.7
.. _Partitioned RFC Draft:
https://datatracker.ietf.org/doc/html/draft-cutler-httpbis-partitioned-cookies#section-2.1
"""

if not is_ascii_encodable(name):
Expand Down Expand Up @@ -528,6 +540,9 @@ def set_cookie(

self._cookies[name]['samesite'] = same_site.capitalize()

if partitioned:
self._cookies[name]['partitioned'] = True

Check warning on line 544 in falcon/response.py

View check run for this annotation

Codecov / codecov/patch

falcon/response.py#L544

Added line #L544 was not covered by tests

def unset_cookie(self, name, samesite='Lax', domain=None, path=None):
"""Unset a cookie in the response.
Expand Down
12 changes: 11 additions & 1 deletion falcon/testing/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,11 @@ class Cookie:
transmitted from the client via HTTPS.
http_only (bool): Whether or not the cookie may only be
included in unscripted requests from the client.
same_site (str): Specifies whether cookies are send in
cross-site requests. Possible values are 'Lax', 'Strict'
and 'None'. ``None`` if not specified.
partitioned (bool): Whether third-party cookies should be stored
with 2 keys.
"""

def __init__(self, morsel):
Expand All @@ -113,6 +118,7 @@ def __init__(self, morsel):
'secure',
'httponly',
'samesite',
'partitioned',
):
value = morsel[name.replace('_', '-')] or None
setattr(self, '_' + name, value)
Expand Down Expand Up @@ -153,9 +159,13 @@ def http_only(self) -> bool:
return bool(self._httponly) # type: ignore[attr-defined]

@property
def same_site(self) -> Optional[int]:
def same_site(self) -> Optional[str]:
return self._samesite if self._samesite else None # type: ignore[attr-defined]

@property
def partitioned(self) -> bool:
return bool(self._partitioned) # type: ignore[attr-defined]

Check warning on line 167 in falcon/testing/client.py

View check run for this annotation

Codecov / codecov/patch

falcon/testing/client.py#L167

Added line #L167 was not covered by tests


class _ResultBase:
"""Base class for the result of a simulated request.
Expand Down
4 changes: 4 additions & 0 deletions falcon/util/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,10 @@
_reserved_cookie_attrs = http_cookies.Morsel._reserved # type: ignore
if 'samesite' not in _reserved_cookie_attrs: # pragma: no cover
_reserved_cookie_attrs['samesite'] = 'SameSite' # type: ignore
# NOTE(m-mueller): Same for the 'partitioned' attribute that will
# probably be added in Python 3.13.
if 'partitioned' not in _reserved_cookie_attrs: # pragma: no cover
_reserved_cookie_attrs['partitioned'] = 'Partitioned'


IS_64_BITS = sys.maxsize > 2**32
Expand Down
23 changes: 23 additions & 0 deletions tests/test_cookies.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,13 @@ def on_delete(self, req, resp):
resp.set_cookie('baz', 'foo', same_site='')


class CookieResourcePartitioned:
def on_get(self, req, resp):
resp.set_cookie('foo', 'bar', secure=True, partitioned=True)
resp.set_cookie('bar', 'baz', secure=True, partitioned=False)
resp.set_cookie('baz', 'foo', secure=True)


class CookieUnset:
def on_get(self, req, resp):
resp.unset_cookie('foo')
Expand Down Expand Up @@ -103,6 +110,7 @@ def client(asgi):
app.add_route('/', CookieResource())
app.add_route('/test-convert', CookieResourceMaxAgeFloatString())
app.add_route('/same-site', CookieResourceSameSite())
app.add_route('/partitioned', CookieResourcePartitioned())
app.add_route('/unset-cookie', CookieUnset())
app.add_route('/unset-cookie-same-site', CookieUnsetSameSite())

Expand Down Expand Up @@ -160,6 +168,7 @@ def test_response_complex_case(client):
assert cookie.max_age == 300
assert cookie.path is None
assert cookie.secure
assert not cookie.partitioned

cookie = result.cookies['bar']
assert cookie.value == 'baz'
Expand All @@ -169,6 +178,7 @@ def test_response_complex_case(client):
assert cookie.max_age is None
assert cookie.path is None
assert cookie.secure
assert not cookie.partitioned

cookie = result.cookies['bad']
assert cookie.value == '' # An unset cookie has an empty value
Expand Down Expand Up @@ -511,3 +521,16 @@ def test_invalid_same_site_value(same_site):

with pytest.raises(ValueError):
resp.set_cookie('foo', 'bar', same_site=same_site)


def test_partitioned_value(client):
result = client.simulate_get('/partitioned')

cookie = result.cookies['foo']
assert cookie.partitioned

cookie = result.cookies['bar']
assert not cookie.partitioned

cookie = result.cookies['baz']
assert not cookie.partitioned

0 comments on commit bd1fbe7

Please sign in to comment.