CVE-2023-40570 (Python, published 2023-08-25T01:15Z)
Description: 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.
Severity: nan (CVSS v3 base score: 5.3)
Weakness: NVD-CWE-noinfo \u2014 Insufficient Information
There is insufficient information about the issue to classify it; details are unkown or unspecified.
Repository: https://github.com/simonw/datasette
Files changed:
datasette/templates/api_explorer.html
datasette/version.py
datasette/views/special.py
docs/changelog.rst
tests/test_permissions.py
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

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