CVE-2023-40570 (published 2023-08-25T01:15Z)

Datasette is an open source multi-tool for exploring and publishing data. This bug affects Datasette instances running a Datasette 1.0 alpha - 1.0a0, 1.0a1, 1.0a2 or 1.0a3 - in an online accessible location but with authentication enabled using a plugin such as datasette-auth-passwords. The `/-/api` API explorer endpoint could reveal the names of both databases and tables - but not their contents - to an unauthenticated user. Datasette 1.0a4 has a fix for this issue. This will block access to the API explorer but will still allow access to the Datasette read or write JSON APIs, as those use different URL patterns within the Datasette `/database` hierarchy. This issue is patched in version 1.0a4.

CVSS v3 base score: 5.3, MEDIUM (CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:N/A:N)

Weakness information:
cwe_id: NVD-CWE-noinfo
cwe_name: Insufficient Information
description: There is insufficient information about the issue to classify it; details are unkown or unspecified.
extended_description: Insufficient Information

Fix information:
repo_url: https://github.com/simonw/datasette
commit_details: repo_url: https://github.com/simonw/datasette
author: Simon Willison
author_date: 2023-08-22 10:10:01-07:00
msg: Merge pull request from GHSA-7ch3-7pp7-7cpq

* API explorer requires view-instance permission

* Check database/table permissions on /-/api page

* Release notes for 1.0a4

Refs #2119, #2133, #2138, #2140

Refs https://github.com/simonw/datasette/security/advisories/GHSA-7ch3-7pp7-7cpq
repository: repo_url: https://github.com/simonw/datasette
repo_name: simonw/datasette
description: An open source multi-tool for exploring and publishing data
repo_language: Python
owner: simonw
file_changes: filename: api_explorer.html
change_type: ModificationType.MODIFY
diff: @@ -8,7 +8,7 @@
 
 {% block content %}
 
-<h1>API Explorer</h1>
+<h1>API Explorer{% if private %} 🔒{% endif %}</h1>
 
 <p>Use this tool to try out the
   {% if datasette_version %}

programming_language: HTML
filename: version.py
change_type: ModificationType.MODIFY
diff: @@ -1,2 +1,2 @@
-__version__ = "1.0a3"
+__version__ = "1.0a4"
 __version_info__ = tuple(__version__.split("."))

programming_language: Python
filename: special.py
change_type: ModificationType.MODIFY
diff: @@ -354,9 +354,7 @@ class ApiExplorerView(BaseView):
             if name == "_internal":
                 continue
             database_visible, _ = await self.ds.check_visibility(
-                request.actor,
-                "view-database",
-                name,
+                request.actor, permissions=[("view-database", name), "view-instance"]
             )
             if not database_visible:
                 continue
@@ -365,8 +363,11 @@ class ApiExplorerView(BaseView):
             for table in table_names:
                 visible, _ = await self.ds.check_visibility(
                     request.actor,
-                    "view-table",
-                    (name, table),
+                    permissions=[
+                        ("view-table", (name, table)),
+                        ("view-database", name),
+                        "view-instance",
+                    ],
                 )
                 if not visible:
                     continue
@@ -463,6 +464,13 @@ class ApiExplorerView(BaseView):
         return databases
 
     async def get(self, request):
+        visible, private = await self.ds.check_visibility(
+            request.actor,
+            permissions=["view-instance"],
+        )
+        if not visible:
+            raise Forbidden("You do not have permission to view this instance")
+
         def api_path(link):
             return "/-/api#{}".format(
                 urllib.parse.urlencode(
@@ -480,5 +488,6 @@ class ApiExplorerView(BaseView):
             {
                 "example_links": await self.example_links(request),
                 "api_path": api_path,
+                "private": private,
             },
         )

programming_language: Python
filename: changelog.rst
change_type: ModificationType.MODIFY
diff: @@ -4,6 +4,22 @@
 Changelog
 =========
 
+.. _v1_0_a4:
+
+1.0a4 (2023-08-21)
+------------------
+
+This alpha fixes a security issue with the ``/-/api`` API explorer. On authenticated Datasette instances (instances protected using plugins such as `datasette-auth-passwords <https://datasette.io/plugins/datasette-auth-passwords>`__) the API explorer interface could reveal the names of databases and tables within the protected instance. The data stored in those tables was not revealed.
+
+For more information and workarounds, read `the security advisory <https://github.com/simonw/datasette/security/advisories/GHSA-7ch3-7pp7-7cpq>`__. The issue has been present in every previous alpha version of Datasette 1.0: versions 1.0a0, 1.0a1, 1.0a2 and 1.0a3.
+
+Also in this alpha:
+
+- The new ``datasette plugins --requirements`` option outputs a list of currently installed plugins in Python ``requirements.txt`` format, useful for duplicating that installation elsewhere. (:issue:`2133`)
+- :ref:`canned_queries_writable` can now define a ``on_success_message_sql`` field in their configuration, containing a SQL query that should be executed upon successful completion of the write operation in order to generate a message to be shown to the user. (:issue:`2138`)
+- The automatically generated border color for a database is now shown in more places around the application. (:issue:`2119`)
+- Every instance of example shell script code in the documentation should now include a working copy button, free from additional syntax. (:issue:`2140`)
+
 .. _v1_0_a3:
 
 1.0a3 (2023-08-09)

programming_language: Markdown
filename: test_permissions.py
change_type: ModificationType.MODIFY
diff: @@ -53,6 +53,7 @@ async def perms_ds():
     (
         "/",
         "/fixtures",
+        "/-/api",
         "/fixtures/compound_three_primary_keys",
         "/fixtures/compound_three_primary_keys/a,a,a",
         "/fixtures/two",  # Query
@@ -951,3 +952,69 @@ def test_cli_create_token(options, expected):
     )
     assert 0 == result2.exit_code, result2.output
     assert json.loads(result2.output) == {"actor": expected}
+
+
+_visible_tables_re = re.compile(r">\/((\w+)\/(\w+))\.json<\/a> - Get rows for")
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+    "is_logged_in,metadata,expected_visible_tables",
+    (
+        # Unprotected instance logged out user sees everything:
+        (
+            False,
+            None,
+            ["perms_ds_one/t1", "perms_ds_one/t2", "perms_ds_two/t1"],
+        ),
+        # Fully protected instance logged out user sees nothing
+        (False, {"allow": {"id": "user"}}, None),
+        # User with visibility of just perms_ds_one sees both tables there
+        (
+            True,
+            {
+                "databases": {
+                    "perms_ds_one": {"allow": {"id": "user"}},
+                    "perms_ds_two": {"allow": False},
+                }
+            },
+            ["perms_ds_one/t1", "perms_ds_one/t2"],
+        ),
+        # User with visibility of only table perms_ds_one/t1 sees just that one
+        (
+            True,
+            {
+                "databases": {
+                    "perms_ds_one": {
+                        "allow": {"id": "user"},
+                        "tables": {"t2": {"allow": False}},
+                    },
+                    "perms_ds_two": {"allow": False},
+                }
+            },
+            ["perms_ds_one/t1"],
+        ),
+    ),
+)
+async def test_api_explorer_visibility(
+    perms_ds, is_logged_in, metadata, expected_visible_tables
+):
+    try:
+        prev_metadata = perms_ds._metadata_local
+        perms_ds._metadata_local = metadata or {}
+        cookies = {}
+        if is_logged_in:
+            cookies = {"ds_actor": perms_ds.client.actor_cookie({"id": "user"})}
+        response = await perms_ds.client.get("/-/api", cookies=cookies)
+        if expected_visible_tables:
+            assert response.status_code == 200
+            # Search HTML for stuff matching:
+            # '>/perms_ds_one/t2.json</a> - Get rows for'
+            visible_tables = [
+                match[0] for match in _visible_tables_re.findall(response.text)
+            ]
+            assert visible_tables == expected_visible_tables
+        else:
+            assert response.status_code == 403
+    finally:
+        perms_ds._metadata_local = prev_metadata

programming_language: Python