Protect personalized dynamic responses from shared caching

This commit is contained in:
Simon Willison 2026-09-10 16:16:44 -07:00
commit 910636e9ac
4 changed files with 74 additions and 6 deletions

View file

@ -1279,7 +1279,7 @@ class Datasette:
if not database.is_mutable:
await database.table_counts(limit=60 * 60 * 1000)
asgi = asgi_csrf.asgi_csrf(
csrf_app = asgi_csrf.asgi_csrf(
DatasetteRouter(self, routes),
signing_secret=self._secret,
cookie_name="ds_csrftoken",
@ -1287,6 +1287,25 @@ class Datasette:
pm.hook.skip_csrf(datasette=self, scope=scope)
),
)
async def asgi(scope, receive, send):
async def send_with_cookie_privacy(message):
# CSRF cookies are added outside the router's response wrapper.
# Apply their privacy policy after that middleware has run.
if message["type"] == "http.response.start":
headers = message.get("headers", [])
if any(key.lower() == b"set-cookie" for key, _ in headers):
headers = [
(key, value)
for key, value in headers
if key.lower() != b"cache-control"
]
headers.append((b"cache-control", b"private, no-store"))
message = dict(message, headers=headers)
await send(message)
await csrf_app(scope, receive, send_with_cookie_privacy)
if self.setting("trace_debug"):
asgi = AsgiTracer(asgi)
asgi = AsgiLifespan(asgi)
@ -1327,6 +1346,49 @@ class DatasetteRouter:
path = "/" + path[len(base_url) :]
scope = dict(scope, route_path=path)
request = Request(scope, receive)
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)
):
# Apply privacy after rendering, including streaming responses
# and errors. Even a public resource can have actor-specific 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"))
# Preserve variation specified by views and plugins, and ensure
# anonymous responses are not reused for credentialed requests.
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(
@ -1352,8 +1414,7 @@ class DatasetteRouter:
break
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

@ -313,6 +313,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 successful responses from an actual static handler bypass privacy.
inner_static._datasette_static = True
return inner_static

View file

@ -195,6 +195,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

@ -182,9 +182,12 @@ def test_custom_params(canned_write_client):
def test_vary_header(canned_write_client):
# These forms embed a csrftoken so they should be served with Vary: Cookie
assert "vary" not in canned_write_client.get("/data").headers
assert "Cookie" == canned_write_client.get("/data/update_name").headers["vary"]
# Dynamic pages vary by credentials, including forms with CSRF cookies.
for path in ("/data", "/data/update_name"):
response = canned_write_client.get(path)
assert {
value.strip().lower() for value in response.headers["vary"].split(",")
} == {"cookie", "authorization"}
def test_json_post_body(canned_write_client):