CVE-2023-40570: Insufficient Information (NVD-CWE-noinfo)
value: 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.
CWE description: There is insufficient information about the issue to classify it; details are unkown or unspecified.
CVSS v3 base score: 5.3
Published: 2023-08-25T01:15Z
Repository: https://github.com/simonw/datasette
Language: Python
Files changed: datasette/templates/api_explorer.html, datasette/version.py, datasette/views/special.py, docs/changelog.rst, tests/test_permissions.py
Commit date: 2023-08-22 10:10:01 -0700
Commit message: 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
Diff: diff --git a/datasette/templates/api_explorer.html b/datasette/templates/api_explorer.html
index ea95c02..109fb1e 100644
--- a/datasette/templates/api_explorer.html
+++ b/datasette/templates/api_explorer.html
@@ -6,11 +6,11 @@
 <script src="{{ base_url }}-/static/json-format-highlight-1.0.1.js"></script>
 {% endblock %}
 
 {% block content %}
 
-<h1>API Explorer</h1>
+<h1>API Explorer{% if private %} 🔒{% endif %}</h1>
 
 <p>Use this tool to try out the
   {% if datasette_version %}
     <a href="https://docs.datasette.io/en/{{ datasette_version }}/json_api.html">Datasette API</a>.
   {% else %}
diff --git a/datasette/version.py b/datasette/version.py
index 61dee46..1d00335 100644
--- a/datasette/version.py
+++ b/datasette/version.py
@@ -1,2 +1,2 @@
-__version__ = "1.0a3"
+__version__ = "1.0a4"
 __version_info__ = tuple(__version__.split("."))
diff --git a/datasette/views/special.py b/datasette/views/special.py
index 03e085d..c45a3ec 100644
--- a/datasette/views/special.py
+++ b/datasette/views/special.py
@@ -352,23 +352,24 @@ class ApiExplorerView(BaseView):
         databases = []
         for name, db in self.ds.databases.items():
             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
             tables = []
             table_names = await db.table_names()
             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
                 table_links = []
                 tables.append({"name": table, "links": table_links})
@@ -461,10 +462,17 @@ class ApiExplorerView(BaseView):
         # Sort so that mutable databases are first
         databases.sort(key=lambda d: not self.ds.databases[d["name"]].is_mutable)
         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(
                     {
                         key: json.dumps(value, indent=2) if key == "json" else value
@@ -478,7 +486,8 @@ class ApiExplorerView(BaseView):
             ["api_explorer.html"],
             request,
             {
                 "example_links": await self.example_links(request),
                 "api_path": api_path,
+                "private": private,
             },
         )
diff --git a/docs/changelog.rst b/docs/changelog.rst
index c497ea9..937610b 100644
--- a/docs/changelog.rst
+++ b/docs/changelog.rst
@@ -2,10 +2,26 @@
 
 =========
 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)
 ------------------
 
diff --git a/tests/test_permissions.py b/tests/test_permissions.py
index caad588..f940d48 100644
--- a/tests/test_permissions.py
+++ b/tests/test_permissions.py
@@ -51,10 +51,11 @@ async def perms_ds():
 @pytest.mark.parametrize(
     "path",
     (
         "/",
         "/fixtures",
+        "/-/api",
         "/fixtures/compound_three_primary_keys",
         "/fixtures/compound_three_primary_keys/a,a,a",
         "/fixtures/two",  # Query
     ),
 )
@@ -949,5 +950,71 @@ def test_cli_create_token(options, expected):
             token,
         ],
     )
     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

Vulnerable code: <h1>API Explorer</h1>
__version__ = "1.0a3"
                request.actor,
                "view-database",
                name,
                    "view-table",
                    (name, table),
Fixed code: <h1>API Explorer{% if private %} 🔒{% endif %}</h1>
__version__ = "1.0a4"
                request.actor, permissions=[("view-database", name), "view-instance"]
                    permissions=[
                        ("view-table", (name, table)),
                        ("view-database", name),
                        "view-instance",
                    ],
        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")

                "private": private,
.. _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`)

        "/-/api",


_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