diff --git a/datasette/app.py b/datasette/app.py index e6d070ea..da36c3f3 100644 --- a/datasette/app.py +++ b/datasette/app.py @@ -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) diff --git a/datasette/utils/asgi.py b/datasette/utils/asgi.py index 2614ad02..6dac071b 100644 --- a/datasette/utils/asgi.py +++ b/datasette/utils/asgi.py @@ -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 diff --git a/docs/settings.rst b/docs/settings.rst index 5fd05d33..9fd51554 100644 --- a/docs/settings.rst +++ b/docs/settings.rst @@ -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 diff --git a/tests/test_stored_queries.py b/tests/test_stored_queries.py index 4be96b30..e0b0e6c6 100644 --- a/tests/test_stored_queries.py +++ b/tests/test_stored_queries.py @@ -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):