diff --git a/datasette/app.py b/datasette/app.py index 5afca2a2..dd5d7e8c 100644 --- a/datasette/app.py +++ b/datasette/app.py @@ -2168,6 +2168,18 @@ class Datasette: for name, d in self.databases.items() ] + async def _connected_databases_for_actor(self, actor): + page = await self.allowed_resources("view-database", actor) + allowed_names = {resource.parent async for resource in page.all()} + return [ + database + for database in self._connected_databases() + if database["name"] in allowed_names + ] + + async def _databases_data(self, request): + return {"databases": await self._connected_databases_for_actor(request.actor)} + def _versions(self): conn = sqlite3.connect(":memory:") self._prepare_connection(conn, "_memory") @@ -2574,7 +2586,8 @@ class Datasette: JsonDataView.as_view( self, "databases.json", - lambda: {"databases": self._connected_databases()}, + self._databases_data, + needs_request=True, ), r"/-/databases(\.(?Pjson))?$", ) diff --git a/datasette/views/special.py b/datasette/views/special.py index c289a240..82a76e76 100644 --- a/datasette/views/special.py +++ b/datasette/views/special.py @@ -53,9 +53,9 @@ class JsonDataView(BaseView): if self.permission: await self.ds.ensure_permission(action=self.permission, actor=request.actor) if self.needs_request: - data = self.data_callback(request) + data = await await_me_maybe(self.data_callback(request)) else: - data = self.data_callback() + data = await await_me_maybe(self.data_callback()) # Return JSON or HTML depending on format parameter as_format = request.url_vars.get("format") diff --git a/docs/introspection.rst b/docs/introspection.rst index 0010e8b7..37f258d7 100644 --- a/docs/introspection.rst +++ b/docs/introspection.rst @@ -137,7 +137,7 @@ Any keys that include the one of the following substrings in their names will be /-/databases ------------ -Shows currently attached databases. `Databases example `_: +Shows currently attached databases that the current actor is allowed to view, based on the ``view-database`` permission. `Databases example `_: .. code-block:: json diff --git a/existing-api.md b/existing-api.md index 5168819a..722331cd 100644 --- a/existing-api.md +++ b/existing-api.md @@ -262,8 +262,7 @@ Permission `view-instance`. No parameters. Response: `{"ok": true, "databases": [...]}` — each database is `{"name", "route", "path", "size", "is_mutable", "is_memory", "hash"}`. -**All attached databases are listed regardless of per-database -`view-database` permissions**. +Only databases the actor is allowed to `view-database` are listed. ### GET /-/actor(.json) diff --git a/stable-api-recommendations.md b/stable-api-recommendations.md index bee4b414..a8690f51 100644 --- a/stable-api-recommendations.md +++ b/stable-api-recommendations.md @@ -260,12 +260,13 @@ Concerns: ## 6. Permissions and security consistency (P1/P2) -- **(P1) `/-/databases.json` ignores per-database permissions** — it lists +- ~~**(P1) `/-/databases.json` ignores per-database permissions** — it lists every attached database (name, path on disk, size) to any actor holding `view-instance` (app.py:2157-2169), while the homepage and every other endpoint filter by `view-database`. On a public instance with private databases this leaks filesystem paths and database names. Filter it, or - gate it behind `permissions-debug`. + gate it behind `permissions-debug`.~~ ✅ **Done** — the endpoint now + filters through `allowed_resources("view-database", actor)`. - **(P2) `/db/-/schema` checks existence before permission** (views/special.py:1308-1317): an actor without `view-database` can distinguish "database exists" (403) from "does not exist" (404). @@ -377,8 +378,8 @@ Two details make tiering urgent rather than optional: SQL errors). 4. ~~Wrap `/-/plugins`, `/-/databases`, `/-/actions` top-level arrays in objects (§2).~~ ✅ Done. -5. Filter `/-/databases.json` by `view-database` or gate it behind - `permissions-debug` (§6). +5. ~~Filter `/-/databases.json` by `view-database` or gate it behind + `permissions-debug` (§6).~~ ✅ Done. 6. 401 (not silent-anonymous) for invalid/expired bearer tokens (§1c). 7. Publish explicit stability tiers, including extras and pagination-token opacity (§9). diff --git a/tests/test_permissions.py b/tests/test_permissions.py index 7d99213b..32606789 100644 --- a/tests/test_permissions.py +++ b/tests/test_permissions.py @@ -1800,3 +1800,35 @@ async def test_root_allow_block_with_table_restricted_actor(): actor=admin_actor, ) assert result is True + + +@pytest.mark.asyncio +async def test_databases_json_respects_view_database(tmp_path_factory): + # https://github.com/simonw/datasette - /-/databases should not list + # databases the actor is not allowed to view + db_directory = tmp_path_factory.mktemp("dbs") + from datasette.utils import sqlite3 as _sqlite3 + + paths = [] + for name in ("public", "private"): + path = str(db_directory / "{}.db".format(name)) + conn = _sqlite3.connect(path) + conn.execute("vacuum") + conn.close() + paths.append(path) + ds = Datasette( + paths, + config={"databases": {"private": {"allow": {"id": "root"}}}}, + ) + ds.root_enabled = True + await ds.invoke_startup() + try: + anon_response = await ds.client.get("/-/databases.json") + assert anon_response.status_code == 200 + anon_names = {db["name"] for db in anon_response.json()["databases"]} + assert anon_names == {"public"} + root_response = await ds.client.get("/-/databases.json", actor={"id": "root"}) + root_names = {db["name"] for db in root_response.json()["databases"]} + assert root_names == {"public", "private"} + finally: + ds.close()