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 835dcbd29..1c03ea3a6 100644 --- a/falcon/util/misc.py +++ b/falcon/util/misc.py @@ -63,6 +63,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( @@ -170,7 +174,14 @@ 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() + 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 @@ -178,7 +189,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', @@ -190,7 +203,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 84e09d33d..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' @@ -172,7 +173,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 @@ -194,7 +195,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) @@ -232,7 +233,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 @@ -255,7 +256,7 @@ def test_cookie_expires_naive(client): cookie = result.cookies['foo'] assert cookie.value == 'bar' assert cookie.domain is None - assert cookie.expires == datetime(year=2050, month=1, day=1) + 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 @@ -268,7 +269,9 @@ def test_cookie_expires_aware(client): 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 @@ -326,7 +329,7 @@ def test_response_unset_cookie(): assert match expiration = http_date_to_dt(match.group(1), obs_date=True) - assert expiration < utcnow_naive() + assert expiration < _utcnow() # ===================================================================== diff --git a/tests/test_request_attrs.py b/tests/test_request_attrs.py index 81c8becbf..67869951b 100644 --- a/tests/test_request_attrs.py +++ b/tests/test_request_attrs.py @@ -702,7 +702,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 ec98da01b..bc00b8655 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -129,21 +129,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): @@ -151,7 +152,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') @@ -161,11 +162,14 @@ 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) + + 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({}) == ''