mirror of
https://github.com/simonw/datasette.git
synced 2026-09-12 11:34:06 +02:00
Protect personalized dynamic responses from shared caching
This commit is contained in:
parent
7e6039b8df
commit
ceef351622
4 changed files with 55 additions and 7 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue