Reject structured row writes to virtual and shadow tables

This commit is contained in:
Simon Willison 2026-09-09 08:49:00 -07:00
commit e036907fc3
6 changed files with 106 additions and 1 deletions

View file

@ -121,6 +121,24 @@ def sqlite_table_type(
return _sqlite_table_type_from_schema(conn, table, schema=schema)
def check_structured_write_table(conn, table: str, *, allow_missing=False):
"""Validate a row-write target on the connection that will perform the write."""
# SQLite resolves identifiers case-insensitively. The create API must not
# treat a differently cased existing name as a missing table.
row = conn.execute(
"select name from main.sqlite_master where name = ? collate nocase "
"and type in ('table', 'view')",
(table,),
).fetchone()
if row is None and allow_missing:
return
if row is not None and sqlite_table_type(conn, row[0]) == "table":
return
# Virtual table modules can interpret row writes as administrative operations.
# Their shadow tables are internal storage, not independently writable data.
raise ValueError("Structured writes require an ordinary table")
def sqlite_hidden_table_names(conn, *, schema: str | None = "main") -> list[str]:
schema_table = _sqlite_schema_table(schema)
try:

View file

@ -32,6 +32,7 @@ from datasette.utils import (
to_css_class,
)
from datasette.utils.asgi import Forbidden, NotFound, PayloadTooLarge, Response
from datasette.utils.sqlite import check_structured_write_table
from . import Context, from_extra
from .base import BaseView, DatasetteError, stream_csv
@ -786,6 +787,7 @@ class RowDeleteView(BaseView):
# Delete table
def delete_row(conn):
check_structured_write_table(conn, resolved.table)
sqlite_utils.Database(conn)[resolved.table].delete(resolved.pk_values)
try:
@ -868,6 +870,7 @@ class RowUpdateView(BaseView):
return Response.error(["Permission denied for alter-table"], 403)
def update_row(conn):
check_structured_write_table(conn, resolved.table)
sqlite_utils.Database(conn)[resolved.table].update(
resolved.pk_values, update, alter=alter
)

View file

@ -57,6 +57,7 @@ from datasette.utils.asgi import (
Request,
Response,
)
from datasette.utils.sqlite import check_structured_write_table
from . import Context, from_extra
from .base import BaseView, DatasetteError, stream_csv
@ -1126,6 +1127,7 @@ class TableInsertView(BaseView):
row_pk_values_for_later = [tuple(row[pk] for pk in pks) for row in rows]
def insert_or_upsert_rows(conn):
check_structured_write_table(conn, table_name)
table = sqlite_utils.Database(conn)[table_name]
kwargs = {}
if upsert:

View file

@ -32,7 +32,10 @@ from datasette.utils.permissions import (
gather_permission_sql_from_hooks,
resolve_permissions_with_candidates,
)
from datasette.utils.sqlite import sqlite_hidden_table_names
from datasette.utils.sqlite import (
check_structured_write_table,
sqlite_hidden_table_names,
)
from .base import BaseView
@ -924,6 +927,7 @@ class TableCreateView(BaseView):
)
def create_table(conn):
check_structured_write_table(conn, table_name, allow_missing=True)
db_for_write = sqlite_utils.Database(conn)
table = db_for_write[table_name]
if rows:

View file

@ -1661,6 +1661,8 @@ The request body is always parsed as JSON, regardless of the request's ``Content
The row-based write APIs can write :ref:`binary values in JSON <binary_json_format>` using Datasette's Base64 representation for BLOB data.
Structured inserts, upserts, updates and deletes only support ordinary SQLite tables. Virtual tables and their internal shadow tables are rejected, including when adding rows to an existing table through the create-table API. Writes to ordinary content tables can still update full-text search indexes through configured triggers.
.. _ExecuteWriteView:
Executing write SQL

View file

@ -68,6 +68,82 @@ BASE64_WRITE_API_VALUE = {"$base64": True, "encoded": "AAEC/f7/"}
BASE64_WRITE_API_LITERAL = '{"$base64": true, "encoded": "AAEC/f7/"}'
@pytest.mark.asyncio
@pytest.mark.parametrize("use_fallback", (False, True))
@pytest.mark.parametrize(
"operation", ("insert", "upsert", "update", "delete", "create", "create_uppercase")
)
@pytest.mark.parametrize(
"module,definition,values,shadow_suffix",
(
("fts5", "body", "'original'", "_content"),
("fts4", "body", "'original'", "_content"),
("rtree", "id, minx, maxx", "1, 0, 1", "_rowid"),
),
)
@pytest.mark.parametrize("shadow", (False, True))
async def test_structured_writes_require_ordinary_tables(
ds_write,
monkeypatch,
use_fallback,
operation,
module,
definition,
values,
shadow_suffix,
shadow,
):
if use_fallback:
monkeypatch.setattr("datasette.utils.sqlite.supports_table_list", lambda: False)
db = ds_write.get_database("data")
await db.execute_write(f"create virtual table indexed using {module}({definition})")
await db.execute_write(f"insert into indexed values ({values})")
table = "indexed" + (shadow_suffix if shadow else "")
row = (await db.execute(f"select rowid, * from {escape_sqlite(table)}")).dicts()[0]
pks = await db.primary_keys(table)
pk_value = row[pks[0] if pks else "rowid"]
before = await db.execute_fn(lambda conn: list(conn.iterdump()))
if operation in ("create", "create_uppercase"):
path = "/data/-/create"
body = {
"table": table.upper() if operation == "create_uppercase" else table,
"rows": [row],
}
elif operation in ("update", "delete"):
path = f"/data/{table}/{pk_value}/-/{operation}"
body = {"update": row} if operation == "update" else {}
else:
path = f"/data/{table}/-/{operation}"
body = {"rows": [row]}
response = await ds_write.client.post(
path, json=body, headers=_headers(write_token(ds_write))
)
assert response.status_code == 400, response.text
assert response.json()["errors"] == ["Structured writes require an ordinary table"]
assert await db.execute_fn(lambda conn: list(conn.iterdump())) == before
@pytest.mark.asyncio
async def test_structured_writes_to_content_table_maintain_fts(ds_write):
db = ds_write.get_database("data")
await db.execute_write_fn(
lambda conn: sqlite_utils.Database(conn)["docs"].enable_fts(
["title"], create_triggers=True
)
)
response = await ds_write.client.post(
"/data/docs/-/insert",
json={"row": {"id": 1, "title": "ordinary content"}},
headers=_headers(write_token(ds_write)),
)
assert response.status_code == 201, response.text
matches = await db.execute(
"select rowid from docs_fts where docs_fts match ?", ["ordinary"]
)
assert [row[0] for row in matches.rows] == [1]
@pytest.mark.asyncio
async def test_base64_write_api_create_table_infers_blob_and_raw_escapes(ds_write):
token = write_token(ds_write)