From 582050e9191e5beb2582f51c138f635a584b4c68 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20Grossm=C3=BCller?= Date: Sat, 13 Jul 2024 15:26:57 +0200 Subject: [PATCH 1/7] feat(misc): Return timezone aware datetime objects let http_date_to_dt validate timezone information from the provided http-date and return timezone aware datetime objects remove tests for timezone naive variants amend tests Breaks any application relying on naiveness of datetimes or interpretation in local time. Closes https://github.com/falconry/falcon/issues/2182 --- docs/_newsfragments/2182.breakingchange.rst | 2 ++ falcon/util/misc.py | 16 ++++++++++++++-- tests/test_cookies.py | 19 ++++--------------- tests/test_request_attrs.py | 2 +- tests/test_utils.py | 15 ++++++++------- 5 files changed, 29 insertions(+), 25 deletions(-) create mode 100644 docs/_newsfragments/2182.breakingchange.rst diff --git a/docs/_newsfragments/2182.breakingchange.rst b/docs/_newsfragments/2182.breakingchange.rst new file mode 100644 index 000000000..aeeb2e308 --- /dev/null +++ b/docs/_newsfragments/2182.breakingchange.rst @@ -0,0 +1,2 @@ +The function :func:`falcon.http_date_to_dt` now validates http-dates to have the correct +timezone set. It now also returns timezone aware datetime objects. diff --git a/falcon/util/misc.py b/falcon/util/misc.py index bbd3080ed..4cc7e08d6 100644 --- a/falcon/util/misc.py +++ b/falcon/util/misc.py @@ -74,6 +74,10 @@ _UNSAFE_CHARS = re.compile(r'[^a-zA-Z0-9.-]') +_ALLOWED_HTTP_TIMEZONES: Tuple[str, ...] = ('GMT', 'UTC') + +_UTC_TIMEZONE = datetime.timezone.utc + # PERF(kgriffs): Avoid superfluous namespace lookups _strptime: Callable[[str, str], datetime.datetime] = datetime.datetime.strptime _utcnow: Callable[[], datetime.datetime] = functools.partial( @@ -182,6 +186,12 @@ def http_date_to_dt(http_date: str, obs_date: bool = False) -> datetime.datetime Raises: ValueError: http_date doesn't match any of the available time formats """ + date_timezone_str = http_date[-3:] + has_tz_identifier = not date_timezone_str.isdigit() + if date_timezone_str not in _ALLOWED_HTTP_TIMEZONES and has_tz_identifier: + raise ValueError( + 'timezone information of time data %r is not allowed' % http_date + ) if not obs_date: # PERF(kgriffs): This violates DRY, but we do it anyway @@ -189,7 +199,9 @@ def http_date_to_dt(http_date: str, obs_date: bool = False) -> datetime.datetime # over it, and setting up exception handling blocks each # time around the loop, in the case that we don't actually # need to check for multiple formats. - return _strptime(http_date, '%a, %d %b %Y %H:%M:%S %Z') + return _strptime(http_date, '%a, %d %b %Y %H:%M:%S %Z').replace( + tzinfo=_UTC_TIMEZONE + ) time_formats = ( '%a, %d %b %Y %H:%M:%S %Z', @@ -201,7 +213,7 @@ def http_date_to_dt(http_date: str, obs_date: bool = False) -> datetime.datetime # Loop through the formats and return the first that matches for time_format in time_formats: try: - return _strptime(http_date, time_format) + return _strptime(http_date, time_format).replace(tzinfo=_UTC_TIMEZONE) except ValueError: continue diff --git a/tests/test_cookies.py b/tests/test_cookies.py index 1d2e0c847..249bbbc63 100644 --- a/tests/test_cookies.py +++ b/tests/test_cookies.py @@ -29,7 +29,7 @@ def dst(self, dt): def utcnow_naive(): - return datetime.now(timezone.utc).replace(tzinfo=None) + return datetime.now(timezone.utc) class CookieResource: @@ -252,26 +252,15 @@ def test_unset(cookie, samesite='Lax'): assert not result_unset.cookies['baz'].same_site -def test_cookie_expires_naive(client): - result = client.simulate_post('/') - - cookie = result.cookies['foo'] - assert cookie.value == 'bar' - assert cookie.domain is None - assert cookie.expires == datetime(year=2050, month=1, day=1) - assert not cookie.http_only - assert cookie.max_age is None - assert cookie.path is None - assert not cookie.secure - - def test_cookie_expires_aware(client): result = client.simulate_put('/') cookie = result.cookies['foo'] assert cookie.value == 'bar' assert cookie.domain is None - assert cookie.expires == datetime(year=2049, month=12, day=31, hour=23) + assert cookie.expires == datetime( + year=2049, month=12, day=31, hour=23, tzinfo=timezone.utc + ) assert not cookie.http_only assert cookie.max_age is None assert cookie.path is None diff --git a/tests/test_request_attrs.py b/tests/test_request_attrs.py index 8ae5b72dc..307e4bb90 100644 --- a/tests/test_request_attrs.py +++ b/tests/test_request_attrs.py @@ -693,7 +693,7 @@ def test_bogus_content_length_neg(self, asgi): ], ) def test_date(self, asgi, header, attr): - date = datetime.datetime(2013, 4, 4, 5, 19, 18) + date = datetime.datetime(2013, 4, 4, 5, 19, 18, tzinfo=datetime.timezone.utc) date_str = 'Thu, 04 Apr 2013 05:19:18 GMT' headers = {header: date_str} diff --git a/tests/test_utils.py b/tests/test_utils.py index df5ff2fc4..dcb9046a7 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -118,21 +118,22 @@ def test_http_now(self): def test_dt_to_http(self): assert ( - falcon.dt_to_http(datetime(2013, 4, 4)) == 'Thu, 04 Apr 2013 00:00:00 GMT' + falcon.dt_to_http(datetime(2013, 4, 4, tzinfo=timezone.utc)) + == 'Thu, 04 Apr 2013 00:00:00 GMT' ) assert ( - falcon.dt_to_http(datetime(2013, 4, 4, 10, 28, 54)) + falcon.dt_to_http(datetime(2013, 4, 4, 10, 28, 54, tzinfo=timezone.utc)) == 'Thu, 04 Apr 2013 10:28:54 GMT' ) def test_http_date_to_dt(self): assert falcon.http_date_to_dt('Thu, 04 Apr 2013 00:00:00 GMT') == datetime( - 2013, 4, 4 + 2013, 4, 4, tzinfo=timezone.utc ) assert falcon.http_date_to_dt('Thu, 04 Apr 2013 10:28:54 GMT') == datetime( - 2013, 4, 4, 10, 28, 54 + 2013, 4, 4, 10, 28, 54, tzinfo=timezone.utc ) with pytest.raises(ValueError): @@ -140,7 +141,7 @@ def test_http_date_to_dt(self): assert falcon.http_date_to_dt( 'Thu, 04-Apr-2013 10:28:54 GMT', obs_date=True - ) == datetime(2013, 4, 4, 10, 28, 54) + ) == datetime(2013, 4, 4, 10, 28, 54, tzinfo=timezone.utc) with pytest.raises(ValueError): falcon.http_date_to_dt('Sun Nov 6 08:49:37 1994') @@ -150,11 +151,11 @@ def test_http_date_to_dt(self): assert falcon.http_date_to_dt( 'Sun Nov 6 08:49:37 1994', obs_date=True - ) == datetime(1994, 11, 6, 8, 49, 37) + ) == datetime(1994, 11, 6, 8, 49, 37, tzinfo=timezone.utc) assert falcon.http_date_to_dt( 'Sunday, 06-Nov-94 08:49:37 GMT', obs_date=True - ) == datetime(1994, 11, 6, 8, 49, 37) + ) == datetime(1994, 11, 6, 8, 49, 37, tzinfo=timezone.utc) def test_pack_query_params_none(self): assert falcon.to_query_str({}) == '' From e6585c64798b241c314c563dca1d126718270369 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20Grossm=C3=BCller?= Date: Sat, 13 Jul 2024 15:37:19 +0200 Subject: [PATCH 2/7] style(misc): Add ValueError to doscstring --- falcon/util/misc.py | 1 + 1 file changed, 1 insertion(+) diff --git a/falcon/util/misc.py b/falcon/util/misc.py index 4cc7e08d6..a22214c4d 100644 --- a/falcon/util/misc.py +++ b/falcon/util/misc.py @@ -185,6 +185,7 @@ def http_date_to_dt(http_date: str, obs_date: bool = False) -> datetime.datetime Raises: ValueError: http_date doesn't match any of the available time formats + ValueError: http_date doesn't match allowed timezones """ date_timezone_str = http_date[-3:] has_tz_identifier = not date_timezone_str.isdigit() From c4fdd45e6dd1ca5d0c8f68de40f158f349784d9b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20Grossm=C3=BCller?= Date: Sat, 13 Jul 2024 15:51:43 +0200 Subject: [PATCH 3/7] test(misc): Add test for violating timezone --- tests/test_utils.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/test_utils.py b/tests/test_utils.py index dcb9046a7..27e369cca 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -157,6 +157,9 @@ def test_http_date_to_dt(self): 'Sunday, 06-Nov-94 08:49:37 GMT', obs_date=True ) == datetime(1994, 11, 6, 8, 49, 37, tzinfo=timezone.utc) + with pytest.raises(ValueError): + falcon.http_date_to_dt('Thu, 04 Apr 2013 10:28:54 EST') + def test_pack_query_params_none(self): assert falcon.to_query_str({}) == '' From 9e4ccc8b92b6ff9edbba5856d8205b800aa84a39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20Grossm=C3=BCller?= Date: Thu, 18 Jul 2024 12:03:17 +0200 Subject: [PATCH 4/7] refactor(tests): Use already defined _utcnow function --- tests/test_cookies.py | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/tests/test_cookies.py b/tests/test_cookies.py index 55aeb28a2..4ec042ae7 100644 --- a/tests/test_cookies.py +++ b/tests/test_cookies.py @@ -12,6 +12,9 @@ import falcon.testing as testing from falcon.util import http_date_to_dt from falcon.util import TimezoneGMT +from falcon.util.misc import _utcnow + +from _util import create_app # NOQA UNICODE_TEST_STRING = 'Unicode_\xc3\xa6\xc3\xb8' @@ -30,10 +33,6 @@ def dst(self, dt): GMT_PLUS_ONE = TimezoneGMTPlus1() -def utcnow_naive(): - return datetime.now(timezone.utc) - - class CookieResource: def on_get(self, req, resp): resp.set_cookie('foo', 'bar', domain='example.com', path='/') @@ -177,7 +176,7 @@ def test_response_complex_case(client): assert cookie.domain is None assert cookie.same_site == 'Lax' - assert cookie.expires < utcnow_naive() + assert cookie.expires < _utcnow() # NOTE(kgriffs): I know accessing a private attr like this is # naughty of me, but we just need to sanity-check that the @@ -199,7 +198,7 @@ def test(cookie, path, domain, samesite='Lax'): assert cookie.domain == domain assert cookie.path == path assert cookie.same_site == samesite - assert cookie.expires < utcnow_naive() + assert cookie.expires < _utcnow() test(result.cookies['foo'], path=None, domain=None) test(result.cookies['bar'], path='/bar', domain=None) @@ -237,7 +236,7 @@ def test_set(cookie, value, samesite=None): def test_unset(cookie, samesite='Lax'): assert cookie.value == '' # An unset cookie has an empty value assert cookie.same_site == samesite - assert cookie.expires < utcnow_naive() + assert cookie.expires < _utcnow() test_unset(result_unset.cookies['foo'], samesite='Strict') # default: bar is unset with no samesite param, so should go to Lax @@ -320,7 +319,7 @@ def test_response_unset_cookie(client): assert match expiration = http_date_to_dt(match.group(1), obs_date=True) - assert expiration < utcnow_naive() + assert expiration < _utcnow() def test_cookie_timezone(client): From 1a9a66393606e761bc8407d4fea141fabaef0162 Mon Sep 17 00:00:00 2001 From: Federico Caselli Date: Fri, 26 Jul 2024 21:50:46 +0200 Subject: [PATCH 5/7] test: update cookie test --- tests/test_cookies.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/tests/test_cookies.py b/tests/test_cookies.py index dc36cbd68..735403682 100644 --- a/tests/test_cookies.py +++ b/tests/test_cookies.py @@ -263,6 +263,19 @@ def test_unset(cookie, samesite='Lax'): assert not result_unset.cookies['baz'].same_site +def test_cookie_expires_naive(client): + result = client.simulate_post('/') + + cookie = result.cookies['foo'] + assert cookie.value == 'bar' + assert cookie.domain is None + assert cookie.expires == datetime(year=2050, month=1, day=1, tzinfo=timezone.utc) + assert not cookie.http_only + assert cookie.max_age is None + assert cookie.path is None + assert not cookie.secure + + def test_cookie_expires_aware(client): result = client.simulate_put('/') From 73b9204b31518f20aa5741d69aed878e1f4606c2 Mon Sep 17 00:00:00 2001 From: Federico Caselli Date: Fri, 26 Jul 2024 21:54:19 +0200 Subject: [PATCH 6/7] test: remove duplicated import --- tests/test_cookies.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/tests/test_cookies.py b/tests/test_cookies.py index 735403682..3b43eed08 100644 --- a/tests/test_cookies.py +++ b/tests/test_cookies.py @@ -14,8 +14,6 @@ from falcon.util import TimezoneGMT from falcon.util.misc import _utcnow -from _util import create_app # NOQA - UNICODE_TEST_STRING = 'Unicode_\xc3\xa6\xc3\xb8' From db6fe410e4c7ee4d35d2f9721e5c1e3ab845f430 Mon Sep 17 00:00:00 2001 From: Vytautas Liuolia Date: Tue, 27 Aug 2024 21:45:00 +0200 Subject: [PATCH 7/7] chore: fix botched master merge [`test_cookies.py`] --- tests/test_cookies.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_cookies.py b/tests/test_cookies.py index 57164ba6e..46ae0328e 100644 --- a/tests/test_cookies.py +++ b/tests/test_cookies.py @@ -9,6 +9,7 @@ import falcon import falcon.testing as testing from falcon.util import http_date_to_dt +from falcon.util.misc import _utcnow UNICODE_TEST_STRING = 'Unicode_\xc3\xa6\xc3\xb8'