mirror of
https://github.com/simonw/datasette.git
synced 2026-09-12 03:24:18 +02:00
Reject structured row writes to virtual and shadow tables
This commit is contained in:
parent
3f8d8417f6
commit
e036907fc3
6 changed files with 106 additions and 1 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue