Keep private row and table responses out of shared caches

This commit is contained in:
Simon Willison 2026-09-08 21:16:35 -07:00
commit 4b8f3b484d
2 changed files with 37 additions and 12 deletions

View file

@ -263,7 +263,7 @@ class RowView(BaseView):
if ttl is None or not ttl.isdigit():
ttl = self.ds.setting("default_cache_ttl")
return self.set_response_headers(response, ttl)
return self.set_response_headers(response, ttl, request)
async def html(self, request, data, extra_template_data, templates):
extras = {}
@ -376,15 +376,23 @@ class RowView(BaseView):
},
)
def set_response_headers(self, response, ttl):
def set_response_headers(self, response, ttl, request=None):
private = getattr(request, "_datasette_private_response", False)
# Set far-future cache expiry
if self.ds.cache_headers and response.status == 200:
ttl = int(ttl)
if ttl == 0:
ttl_header = "no-cache"
if private:
# This response is only visible to the current actor (denied
# to anonymous requests), so it must never be stored by a
# shared cache/CDN - and ?_ttl= must not override that.
response.headers["Cache-Control"] = "private, no-store"
response.headers["Vary"] = "Cookie"
else:
ttl_header = f"max-age={ttl}"
response.headers["Cache-Control"] = ttl_header
ttl = int(ttl)
if ttl == 0:
ttl_header = "no-cache"
else:
ttl_header = f"max-age={ttl}"
response.headers["Cache-Control"] = ttl_header
response.headers["Referrer-Policy"] = "no-referrer"
if self.ds.cors:
add_cors_headers(response.headers)
@ -405,6 +413,10 @@ class RowView(BaseView):
)
if not visible:
raise Forbidden("You do not have permission to view this table")
# Record whether this response is private (visible to this actor
# only) so set_response_headers() can set appropriate Cache-Control
# headers, regardless of which output format ends up being rendered.
request._datasette_private_response = private
results = await resolved.db.execute(
resolved.sql, resolved.params, truncate=True

View file

@ -1712,13 +1712,22 @@ async def table_view(datasette, request):
if ttl is None or not ttl.isdigit():
ttl = datasette.setting("default_cache_ttl")
private = getattr(request, "_datasette_private_response", False)
if datasette.cache_headers and response.status == 200:
ttl = int(ttl)
if ttl == 0:
ttl_header = "no-cache"
if private:
# This response is only visible to the current actor (denied to
# anonymous requests), so it must never be stored by a shared
# cache/CDN - and ?_ttl= must not be able to override that.
response.headers["Cache-Control"] = "private, no-store"
response.headers["Vary"] = "Cookie"
else:
ttl_header = f"max-age={ttl}"
response.headers["Cache-Control"] = ttl_header
ttl = int(ttl)
if ttl == 0:
ttl_header = "no-cache"
else:
ttl_header = f"max-age={ttl}"
response.headers["Cache-Control"] = ttl_header
# Referrer policy
response.headers["Referrer-Policy"] = "no-referrer"
@ -1966,6 +1975,10 @@ async def table_view_data(
)
if not visible:
raise Forbidden("You do not have permission to view this table")
# Record whether this response is private (visible to this actor only)
# so the outer table_view() can set appropriate Cache-Control headers,
# regardless of which output format ends up being rendered.
request._datasette_private_response = private
# Redirect based on request.args, if necessary
redirect_response = await _redirect_if_needed(datasette, request, resolved)