diff --git a/datasette/utils/__init__.py b/datasette/utils/__init__.py index 59ff877c..66fb630b 100644 --- a/datasette/utils/__init__.py +++ b/datasette/utils/__init__.py @@ -343,7 +343,7 @@ def escape_css_string(s): def escape_sqlite(s): - if _boring_keyword_re.match(s) and (s.lower() not in reserved_words): + if _boring_keyword_re.fullmatch(s) and (s.lower() not in reserved_words): return s elif "]" in s: # SQLite does not support escaping ] inside [bracket] quoting, so fall diff --git a/docs/changelog.rst b/docs/changelog.rst index 7d37fcef..5736875c 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -4,6 +4,13 @@ Changelog ========= +.. _v0_65_5: + +0.65.5 (2026-09-16) +------------------- + +- Fixed a security issue where a trailing newline in a requested table name could bypass table permissions and expose private rows. Thanks for the report, `dpfkdlemtp `__. `GHSA-h547-rmjf-5m2m `__ + .. _v0_65_4: 0.65.4 (2026-09-10) diff --git a/tests/test_table_permissions.py b/tests/test_table_permissions.py index 2b82d4c2..c7eb6481 100644 --- a/tests/test_table_permissions.py +++ b/tests/test_table_permissions.py @@ -8,6 +8,48 @@ from datasette.app import Datasette from datasette.plugins import pm +@pytest.mark.asyncio +@pytest.mark.parametrize("create_decoy", [False, True]) +@pytest.mark.parametrize("path", [".json", "/1.json"]) +async def test_trailing_lf_table_permissions(tmp_path, create_decoy, path): + db_path = tmp_path / "data.db" + conn = sqlite3.connect(db_path) + conn.executescript( + "create table secret (id integer primary key, value text);" + "insert into secret values (1, 'private');" + ) + if create_decoy: + conn.executescript( + 'create table "secret\n" (id integer primary key, value text);' + "insert into \"secret\n\" values (1, 'decoy');" + ) + conn.close() + ds = Datasette( + [db_path], + settings={"default_allow_sql": False}, + metadata={"databases": {"data": {"tables": {"secret": {"allow": False}}}}}, + ) + try: + response = await ds.client.get("/data.json?sql=select+*+from+secret") + assert response.status_code == 403 + response = await ds.client.get("/data/secret" + path) + assert response.status_code == 403 + + # A trailing line feed must not bypass the protected table's permissions. + # Row URLs must also be safe when the suffixed table does not exist. + response = await ds.client.get("/data/secret~0A" + path + "?_shape=array") + if create_decoy: + assert response.status_code == 200, response.text + assert response.json() == [{"id": 1, "value": "decoy"}] + elif path == ".json": + assert response.status_code == 404 + else: + assert response.status_code == 400 + assert response.json()["error"] == "no such table: secret\n" + finally: + ds.executor.shutdown(wait=True) + + @pytest.fixture def ds(tmp_path): path = tmp_path / "catalog.db" diff --git a/tests/test_utils.py b/tests/test_utils.py index abb358b4..2696e91b 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -216,6 +216,8 @@ def test_detect_fts(open_quote, close_quote): "identifier,expected", ( ("plain", "plain"), + ("plain\n", "[plain\n]"), + ("select\n", "[select\n]"), ("select", "[select]"), ("has space", "[has space]"), ("has]bracket", '"has]bracket"'),