Protect personalized dynamic responses from shared caching

This commit is contained in:
Simon Willison 2026-09-08 21:16:36 -07:00
commit ceef351622
4 changed files with 55 additions and 7 deletions

View file

@ -3048,6 +3048,50 @@ class DatasetteRouter:
receive,
max_post_body_bytes=self.ds.setting("max_post_body_bytes"),
)
match, view = resolve_routes(self.routes, path)
is_static = view is favicon or getattr(view, "_datasette_static", False)
original_send = send
async def send(message):
if message["type"] == "http.response.start" and not (
is_static and message["status"] in (200, 304)
):
# Decide privacy after rendering, including for streaming responses
# and error handlers. A public primary resource can still include
# private labels, actor navigation, or cookie-dependent content.
headers = list(message.get("headers", []))
personalized = (
request.actor is not None
or "cookie" in request.headers
or "authorization" in request.headers
or any(key.lower() == b"set-cookie" for key, _ in headers)
)
if personalized:
headers = [
(key, value)
for key, value in headers
if key.lower() != b"cache-control"
]
headers.append((b"cache-control", b"private, no-store"))
# Anonymous responses must not be reused for credentialed requests.
# Preserve any additional variation specified by views or plugins.
vary = [
part.strip()
for key, value in headers
if key.lower() == b"vary"
for part in value.split(b",")
if part.strip()
]
if b"*" not in vary:
for name in (b"Cookie", b"Authorization"):
if name.lower() not in {part.lower() for part in vary}:
vary.append(name)
headers = [(k, v) for k, v in headers if k.lower() != b"vary"]
headers.append((b"vary", b", ".join(vary)))
message = dict(message, headers=headers)
await original_send(message)
# Populate request_messages if ds_messages cookie is present
try:
request._messages = self.ds.unsign(
@ -3087,8 +3131,7 @@ class DatasetteRouter:
return await self.handle_401(request, send, token_error)
scope_modifications["actor"] = actor or default_actor
scope = dict(scope, **scope_modifications)
match, view = resolve_routes(self.routes, path)
request.scope = scope
if match is None:
return await self.handle_404(request, send)

View file

@ -498,6 +498,8 @@ def asgi_static(root_path, chunk_size=4096, headers=None, content_type=None):
await asgi_send_html(send, "404: File not found", 404)
return
# Only the actual static-file handler can bypass dynamic response privacy.
inner_static._datasette_static = True
return inner_static

View file

@ -256,6 +256,8 @@ Default HTTP caching max-age header in seconds, used for ``Cache-Control: max-ag
datasette mydatabase.db --setting default_cache_ttl 60
Dynamic responses for authenticated actors, requests with cookies or an ``Authorization`` header, and responses that set cookies use ``Cache-Control: private, no-store``. This takes precedence over ``default_cache_ttl`` and ``?_ttl=``, even when cache headers are otherwise disabled. Anonymous dynamic responses vary by ``Cookie`` and ``Authorization``. Static assets retain their own cache policy.
.. _setting_cache_size_kb:
cache_size_kb

View file

@ -208,11 +208,12 @@ def test_custom_params(stored_write_client):
)
def test_stored_query_pages_no_vary_header(stored_write_client):
# These pages no longer embed per-cookie CSRF tokens, so they must not
# set Vary: Cookie - they should be cacheable across users.
assert "vary" not in stored_write_client.get("/data").headers
assert "vary" not in stored_write_client.get("/data/update_name").headers
def test_stored_query_pages_vary_by_credentials(stored_write_client):
# Even without per-cookie CSRF tokens, anonymous pages must not be reused
# for authenticated users whose permissions or navigation can differ.
for path in ("/data", "/data/update_name"):
response = stored_write_client.get(path)
assert response.headers["vary"] == "Cookie, Authorization"
def test_json_post_body(stored_write_client):