diff options
| author | Natalia <124304+nessita@users.noreply.github.com> | 2026-06-02 21:38:35 -0300 |
|---|---|---|
| committer | nessita <124304+nessita@users.noreply.github.com> | 2026-06-08 20:06:46 -0300 |
| commit | 526b1b414d8e215bf627b5722df12a09346dbf6b (patch) | |
| tree | bd1bbda69168233244ec57d7a49fa049f5e566e1 /tests/cache/tests.py | |
| parent | 7f2afdd0f6a872ccb48d7fbdaacce9b11216af52 (diff) | |
Refs CVE-2026-48587 -- Added helper to properly split header values.
Extracted the repeated `split(",")` + per-token `.strip()` pattern into
a `split_header_value()` generator in django/utils/http.py. The previous
`cc_delim_re` regex only stripped whitespace adjacent to the comma
delimiter, leaving leading or trailing whitespace on the first and last
tokens. Now, `split_header_value()` strips every token fully, matching
RFC 9110's optional-whitespace rules.
Thanks to Shai Berger, Jacob Walls, and Sarah Boyce for reviews.
Diffstat (limited to 'tests/cache/tests.py')
| -rw-r--r-- | tests/cache/tests.py | 83 |
1 files changed, 81 insertions, 2 deletions
diff --git a/tests/cache/tests.py b/tests/cache/tests.py index 96a1c5583b..92641240b1 100644 --- a/tests/cache/tests.py +++ b/tests/cache/tests.py @@ -56,8 +56,8 @@ from django.test.signals import setting_changed from django.test.utils import CaptureQueriesContext from django.utils import timezone, translation from django.utils.cache import ( - cc_delim_re, get_cache_key, + get_max_age, has_vary_header, learn_cache_key, patch_cache_control, @@ -2258,6 +2258,61 @@ class CacheUtils(SimpleTestCase): patch_vary_headers(response, newheaders) self.assertEqual(response.headers["Vary"], resulting_vary) + def test_patch_vary_headers_strips_whitespace(self): + headers = ( + # Whitespace-padded tokens in existing Vary must be stripped before + # deduplication so that adding a header already present (with + # surrounding whitespace) does not produce a duplicate entry. + (" Cookie", ("Accept-Encoding",), "Cookie, Accept-Encoding"), + ("Cookie ", ("Cookie",), "Cookie"), + (" Cookie ", ("Cookie",), "Cookie"), + # Tab-padded tokens must also be normalized. + ( + "Cookie, Accept-Encoding", + ("Accept-Encoding\t", "\tcookie"), + "Cookie, Accept-Encoding", + ), + # Whitespace-padded wildcard in existing Vary must be recognized so + # that patch_vary_headers() still outputs a single "*" rather than + # appending new headers alongside the (unrecognized) padded "*". + ("* ", ("Accept-Language",), "*"), + (" *", ("Cookie",), "*"), + (" * ", ("Cookie", "Accept-Language"), "*"), + # Whitespace-padded wildcard supplied as a new header must also be + # recognized and collapsed to a single "*". + (None, (" * ",), "*"), + ("Cookie", (" * ",), "*"), + ("Cookie, Accept-Encoding", (" * ",), "*"), + ) + for initial_vary, newheaders, resulting_vary in headers: + with self.subTest(initial_vary=initial_vary, newheaders=newheaders): + response = HttpResponse() + if initial_vary is not None: + response.headers["Vary"] = initial_vary + patch_vary_headers(response, newheaders) + self.assertEqual(response.headers["Vary"], resulting_vary) + + def test_get_max_age_strips_whitespace(self): + # A max-age directive with surrounding whitespace must be parsed + # correctly; a leading space (e.g. from manual header construction) + # previously caused the directive key to be " max-age" which never + # matched, returning None instead of the integer value. + tests = [ + # Whitespace before directive (no preceding comma). + (" max-age=300", 300), + ("\tmax-age=300", 300), + # Whitespace around a non-first directive after split(","). + ("no-cache, max-age=600", 600), + ("no-cache,\tmax-age=600", 600), + # Whitespace after the value is handled by int() transparently. + ("max-age=300 ", 300), + ] + for header_value, expected in tests: + with self.subTest(header_value=header_value): + response = HttpResponse() + response.headers["Cache-Control"] = header_value + self.assertEqual(get_max_age(response), expected) + def test_get_cache_key(self): request = self.factory.get(self.path) response = HttpResponse() @@ -2317,6 +2372,30 @@ class CacheUtils(SimpleTestCase): "18a03f9c9649f7d684af5db3524f5c99.d41d8cd98f00b204e9800998ecf8427e", ) + def test_learn_cache_key_strips_whitespace(self): + # Vary header tokens with leading or trailing whitespace must be + # stripped before being used as request.META lookup keys, so that the + # generated cache key correctly incorporates the header value rather + # than silently ignoring it. + request_a = self.factory.get( + self.path, headers={"cookie": "a=1", "x-pony": "gold"} + ) + request_b = self.factory.get( + self.path, headers={"cookie": "a=2", "x-pony": "gold"} + ) + + response = HttpResponse() + # Whitespace-padded token: should be treated identically to "Cookie". + response.headers["Vary"] = " Cookie " + learn_cache_key(request_a, response) + + # Requests with different Cookie values must get different cache keys. + key_a = get_cache_key(request_a) + key_b = get_cache_key(request_b) + self.assertIsNotNone(key_a) + self.assertIsNotNone(key_b) + self.assertNotEqual(key_a, key_b) + def test_patch_cache_control(self): tests = ( # Initial Cache-Control, kwargs to patch_cache_control, expected @@ -2366,7 +2445,7 @@ class CacheUtils(SimpleTestCase): if initial_cc is not None: response.headers["Cache-Control"] = initial_cc patch_cache_control(response, **newheaders) - parts = set(cc_delim_re.split(response.headers["Cache-Control"])) + parts = {cc for cc in response.headers["Cache-Control"].split(", ")} self.assertEqual(parts, expected_cc) def test_has_vary_header(self): |
