mirror of
https://github.com/simonw/datasette.git
synced 2026-09-19 23:14:06 +02:00
Protect personalized dynamic responses from shared caching
This commit is contained in:
parent
375f9f0322
commit
910636e9ac
4 changed files with 74 additions and 6 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue