Skip to content

Commit 3cf0fe4

Browse files
gerrod3cursoragent
andcommitted
Fix Simple API cache poisoning via ?format=json
Key the Simple API cache on the negotiated media type so a JSON response from ?format=json cannot be served for a later HTML request with the same Accept header. closes #1302 Assisted By: Cursor Grok 4.5 Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent cd6f078 commit 3cf0fe4

3 files changed

Lines changed: 49 additions & 5 deletions

File tree

CHANGES/1302.bugfix

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Fixed Simple API cache incorrectly serving JSON responses for HTML requests when clients used `?format=json`.

pulp_python/app/cache.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,26 @@
11
from pulpcore.plugin.cache import CacheKeys, SyncContentCache
22
from pulpcore.plugin.util import cache_key
33

4-
ACCEPT_HEADER_KEY = "accept_header"
4+
MEDIA_TYPE_KEY = "media_type"
55

66

77
class PythonApiCache(SyncContentCache):
88
"""
99
Cache for the Simple API.
1010
11-
Adds Accept header to the cache key so HTML and JSON responses are cached separately.
11+
Keys on the negotiated media type so HTML and JSON are cached separately,
12+
including when the same Accept header is used with `?format=json`.
1213
"""
1314

1415
def __init__(self, base_key=None):
15-
keys = (CacheKeys.path, CacheKeys.method, ACCEPT_HEADER_KEY)
16+
keys = (CacheKeys.path, CacheKeys.method, MEDIA_TYPE_KEY)
1617
super().__init__(base_key=base_key, keys=keys)
1718

1819
def make_key(self, request):
1920
all_keys = {
2021
CacheKeys.path: request.path,
2122
CacheKeys.method: request.method,
22-
ACCEPT_HEADER_KEY: request.headers.get("accept", ""),
23+
MEDIA_TYPE_KEY: getattr(request, "accepted_media_type", "") or "",
2324
}
2425
return ":".join(all_keys[k] for k in self.keys)
2526

pulp_python/tests/functional/api/test_simple_cache.py

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from pulp_python.tests.functional.constants import (
77
PYPI_SIMPLE_V1_HTML,
88
PYPI_SIMPLE_V1_JSON,
9+
PYPI_TEXT_HTML,
910
PYTHON_SM_PROJECT_SPECIFIER,
1011
)
1112

@@ -59,7 +60,7 @@ def test_simple_cache_hit_miss_and_headers(synced_distro):
5960
@pytest.mark.parallel
6061
def test_simple_cache_separate_accept_headers(synced_distro):
6162
"""
62-
HTML and JSON responses are cached separately.
63+
HTML and JSON responses are cached separately by negotiated media type.
6364
"""
6465
url = urljoin(synced_distro.base_url, "simple/")
6566

@@ -74,6 +75,47 @@ def test_simple_cache_separate_accept_headers(synced_distro):
7475
assert r.headers["X-PULP-CACHE"] == "HIT"
7576

7677

78+
@pytest.mark.parallel
79+
def test_simple_cache_format_json_does_not_poison_html(synced_distro):
80+
"""
81+
A ?format=json response must not poison a later request with the same Accept.
82+
83+
Clients like uv/pip send an Accept that allows both JSON and HTML. DRF's
84+
?format=json overrides negotiation to JSON, while the same Accept without
85+
that query param selects HTML. Caching must key on the negotiated type so
86+
the JSON entry is not served (and re-rendered) for the HTML request.
87+
"""
88+
url = f"{urljoin(synced_distro.base_url, 'simple/')}aiohttp"
89+
# pip/uv-style Accept: JSON preferred, HTML still acceptable
90+
headers = {
91+
"Accept": (
92+
f"{PYPI_SIMPLE_V1_JSON}, {PYPI_SIMPLE_V1_HTML};q=0.1, {PYPI_TEXT_HTML};q=0.01"
93+
)
94+
}
95+
96+
r_json = requests.get(url, headers=headers, params={"format": "json"})
97+
assert r_json.status_code == 200
98+
assert PYPI_SIMPLE_V1_JSON in r_json.headers["Content-Type"]
99+
assert r_json.headers["X-PULP-CACHE"] == "MISS"
100+
assert r_json.json()["name"] == "aiohttp"
101+
102+
r_html = requests.get(url, headers=headers)
103+
assert r_html.status_code == 200
104+
assert PYPI_TEXT_HTML in r_html.headers["Content-Type"]
105+
assert r_html.headers["X-PULP-CACHE"] == "MISS"
106+
assert b"<a href=" in r_html.content
107+
108+
r_html_hit = requests.get(url, headers=headers)
109+
assert r_html_hit.status_code == 200
110+
assert r_html_hit.headers["X-PULP-CACHE"] == "HIT"
111+
assert PYPI_TEXT_HTML in r_html_hit.headers["Content-Type"]
112+
113+
r_json_hit = requests.get(url, headers=headers, params={"format": "json"})
114+
assert r_json_hit.status_code == 200
115+
assert r_json_hit.headers["X-PULP-CACHE"] == "HIT"
116+
assert PYPI_SIMPLE_V1_JSON in r_json_hit.headers["Content-Type"]
117+
118+
77119
@pytest.mark.parallel
78120
def test_simple_cache_etag_conditional_request(synced_distro):
79121
"""

0 commit comments

Comments
 (0)