diff --git a/datasette/app.py b/datasette/app.py index 82762823..f256f2c8 100644 --- a/datasette/app.py +++ b/datasette/app.py @@ -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) diff --git a/datasette/utils/asgi.py b/datasette/utils/asgi.py index 0f1f98bf..ba73b3a9 100644 --- a/datasette/utils/asgi.py +++ b/datasette/utils/asgi.py @@ -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 diff --git a/docs/settings.rst b/docs/settings.rst index 8a83cc2f..1b98b898 100644 --- a/docs/settings.rst +++ b/docs/settings.rst @@ -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 diff --git a/tests/test_canned_queries.py b/tests/test_canned_queries.py index 976aa0db..ccdcdfea 100644 --- a/tests/test_canned_queries.py +++ b/tests/test_canned_queries.py @@ -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):