From 08df9dc69bcd129c19f6c137b35b14d716d71b45 Mon Sep 17 00:00:00 2001 From: Edson Henrique Date: Tue, 27 Oct 2026 20:24:04 -0400 Subject: [PATCH] Accept quoted numeric Cache-Control arguments --- cachecontrol/controller.py | 7 ++++++- tests/conftest.py | 5 +++++ tests/test_cache_control.py | 40 +++++++++++++++++++++++++++++++++++-- tests/test_max_age.py | 17 ++++++++++++++-- 4 files changed, 64 insertions(+), 5 deletions(-) diff --git a/cachecontrol/controller.py b/cachecontrol/controller.py index b8c75ea8..6c6d9b6e 100644 --- a/cachecontrol/controller.py +++ b/cachecontrol/controller.py @@ -125,7 +125,12 @@ def parse_cache_control(self, headers: Mapping[str, str]) -> dict[str, int | Non retval[directive] = None if typ: try: - retval[directive] = typ(parts[1].strip()) + value = parts[1].strip() + # Recipients ought to accept both token and quoted-string + # arguments (RFC 9111, section 5.2). + if len(value) >= 2 and value[0] == value[-1] == '"': + value = value[1:-1] + retval[directive] = typ(value) except IndexError: if required: logger.debug( diff --git a/tests/conftest.py b/tests/conftest.py index 27685330..62707938 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -76,6 +76,11 @@ def cache_60(self, env, start_response): start_response("200 OK", headers) return [pformat(env).encode("utf8")] + def cache_60_quoted(self, env, start_response): + headers = [("Cache-Control", 'public, max-age="60"')] + start_response("200 OK", headers) + return [pformat(env).encode("utf8")] + def no_cache(self, env, start_response): headers = [("Cache-Control", "no-cache")] start_response("200 OK", headers) diff --git a/tests/test_cache_control.py b/tests/test_cache_control.py index a31c5e9d..7979a77c 100644 --- a/tests/test_cache_control.py +++ b/tests/test_cache_control.py @@ -21,6 +21,41 @@ TIME_FMT = "%a, %d %b %Y %H:%M:%S GMT" +class TestParseCacheControl: + @pytest.mark.parametrize( + "directive", ["max-age", "s-maxage", "max-stale", "min-fresh"] + ) + @pytest.mark.parametrize("value", [0, 600, -1]) + def test_quoted_numeric_value(self, directive, value): + controller = CacheController() + expected = {directive: value} + + assert ( + controller.parse_cache_control({"Cache-Control": f"{directive}={value}"}) + == expected + ) + assert ( + controller.parse_cache_control( + {"Cache-Control": f'{directive}= \t"{value}" \t'} + ) + == expected + ) + + @pytest.mark.parametrize( + "directive", ["max-age", "s-maxage", "max-stale", "min-fresh"] + ) + @pytest.mark.parametrize("value", ['"600', '600"', '"bad"', '""', '"600"extra']) + def test_invalid_numeric_value(self, directive, value): + expected = {"max-stale": None} if directive == "max-stale" else {} + + assert ( + CacheController().parse_cache_control( + {"Cache-Control": f"{directive}={value}"} + ) + == expected + ) + + class TestCacheControllerResponse: url = "http://url.com/" @@ -84,9 +119,10 @@ def test_cache_response_no_cache_control(self, cc): assert not cc.cache.set.called - def test_cache_response_cache_max_age(self, cc): + @pytest.mark.parametrize("value", ["3600", '"3600"']) + def test_cache_response_cache_max_age(self, cc, value): now = time.strftime(TIME_FMT, time.gmtime()) - resp = self.resp({"cache-control": "max-age=3600", "date": now}) + resp = self.resp({"cache-control": f"max-age={value}", "date": now}) req = self.req() cc.cache_response(req, resp) cc.serializer.dumps.assert_called_with(req, resp, None) diff --git a/tests/test_max_age.py b/tests/test_max_age.py index 18fe18d9..ad17c7d5 100644 --- a/tests/test_max_age.py +++ b/tests/test_max_age.py @@ -5,6 +5,7 @@ import pytest from requests import Session +from cachecontrol import CacheControl from cachecontrol.adapter import CacheControlAdapter from cachecontrol.cache import DictCache from .utils import NullSerializer @@ -21,7 +22,8 @@ def sess(self, url): ) return sess - def test_client_max_age_0(self, sess): + @pytest.mark.parametrize("value", ["0", '"0"']) + def test_client_max_age_0(self, sess, value): """ Making sure when the client uses max-age=0 we don't get a cached copy even though we're still fresh. @@ -31,12 +33,23 @@ def test_client_max_age_0(self, sess): assert self.cache.get(self.url) == r.raw print("second request") - r = sess.get(self.url, headers={"Cache-Control": "max-age=0"}) + r = sess.get(self.url, headers={"Cache-Control": f"max-age={value}"}) # don't remove from the cache assert self.cache.get(self.url) assert not r.from_cache + @pytest.mark.parametrize("path", ["cache_60", "cache_60_quoted"]) + def test_server_max_age(self, url, path): + with CacheControl(Session()) as sess: + response = sess.get(url + path) + assert response.from_cache is False + body = response.content + + response = sess.get(url + path) + assert response.from_cache is True + assert response.content == body + def test_client_max_age_3600(self, sess): """ Verify we get a cached value when the client has a