Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(Response): support Partitioned cookie attribute #2248

Merged
merged 6 commits into from
Jul 16, 2024
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@
secure=None,
http_only=True,
same_site=None,
partitioned=False,
):
"""Set a response cookie.

Expand Down Expand Up @@ -447,6 +448,14 @@

(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 @@
.. _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
CaselIT marked this conversation as resolved.
Show resolved Hide resolved

"""

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

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 @@
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.
CaselIT marked this conversation as resolved.
Show resolved Hide resolved
"""

def __init__(self, morsel):
Expand All @@ -113,6 +118,7 @@
'secure',
'httponly',
'samesite',
'partitioned',
):
value = morsel[name.replace('_', '-')] or None
setattr(self, '_' + name, value)
Expand Down Expand Up @@ -153,9 +159,13 @@
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
Loading