diff --git a/datasette/filters.py b/datasette/filters.py index af922eda..83e51165 100644 --- a/datasette/filters.py +++ b/datasette/filters.py @@ -51,13 +51,20 @@ def search_filters(request, database, table, datasette): human_descriptions = [] extra_context = {} - # Figure out which fts_table to use + # Figure out which trusted fts_table to use. Query string parameters can + # repeat this mapping (for backwards compatibility), but must not select + # a different table or primary key. table_metadata = await datasette.table_config(database, table) db = datasette.get_database(database) - fts_table = request.args.get("_fts_table") - fts_table = fts_table or table_metadata.get("fts_table") + fts_table = table_metadata.get("fts_table") fts_table = fts_table or await db.fts_table(table) - fts_pk = request.args.get("_fts_pk", table_metadata.get("fts_pk", "rowid")) + fts_pk = table_metadata.get("fts_pk", "rowid") + requested_fts_table = request.args.get("_fts_table") + requested_fts_pk = request.args.get("_fts_pk") + if (requested_fts_table and requested_fts_table != fts_table) or ( + requested_fts_pk and requested_fts_pk != fts_pk + ): + raise BadRequest("Invalid _fts_table or _fts_pk") search_args = { key: request.args[key] for key in request.args @@ -75,6 +82,11 @@ def search_filters(request, database, table, datasette): extra_context["supports_search"] = bool(fts_table) if fts_table and search_args: + await datasette.ensure_permission( + action="view-table", + resource=TableResource(database=database, table=fts_table), + actor=request.actor, + ) if "_search" in search_args: # Simple ?_search=xxx search = search_args["_search"] diff --git a/docs/full_text_search.rst b/docs/full_text_search.rst index 349ad149..2769d004 100644 --- a/docs/full_text_search.rst +++ b/docs/full_text_search.rst @@ -52,11 +52,11 @@ Configuring full-text search for a table or view If a table has a corresponding FTS table set up using the ``content=`` argument to ``CREATE VIRTUAL TABLE`` shown below, Datasette will detect it automatically and add a search interface to the table page for that table. -You can also manually configure which table should be used for full-text search using query string parameters or table configuration in ``datasette.yaml`` (see :ref:`table_configuration_fts`). You can set the associated FTS table for a specific table and you can also set one for a view - if you do that, the page for that SQL view will offer a search option. +You can also manually configure which table should be used for full-text search using table configuration in ``datasette.yaml`` (see :ref:`table_configuration_fts`). You can set the associated FTS table for a specific table and you can also set one for a view - if you do that, the page for that SQL view will offer a search option. -Use ``?_fts_table=x`` to over-ride the FTS table for a specific page. If the primary key was something other than ``rowid`` you can use ``?_fts_pk=col`` to set that as well. This is particularly useful for views, for example: +The legacy ``?_fts_table=x`` and ``?_fts_pk=col`` query string parameters are accepted only if they exactly match the configured or automatically detected FTS mapping. They cannot be used to select a different FTS table or primary key. This prevents a public table from being used to probe the contents of a private FTS table. -https://latest.datasette.io/fixtures/searchable_view?_fts_table=searchable_fts&_fts_pk=pk +Searching also requires the current actor to have ``view-table`` permission for the FTS table itself, in addition to permission to view the table or view being searched. The ``fts_table`` metadata property can be used to specify an associated FTS table. If the primary key column in your table which was used to populate the FTS table is something other than ``rowid``, you can specify the column to use with the ``fts_pk`` property. diff --git a/tests/test_table_api.py b/tests/test_table_api.py index ec4a1368..58a5ddc3 100644 --- a/tests/test_table_api.py +++ b/tests/test_table_api.py @@ -619,7 +619,10 @@ def test_searchmode(table_metadata, querystring, expected_rows): ], ), ( - "/fixtures/searchable_view.json?_shape=arrays&_search=weasel&_fts_table=searchable_fts&_fts_pk=pk", + ( + "/fixtures/searchable_view_configured_by_metadata.json" + "?_shape=arrays&_search=weasel&_fts_table=searchable_fts&_fts_pk=pk" + ), [[2, "terry dog", "sara weasel", "puma"]], ), ], diff --git a/tests/test_table_html.py b/tests/test_table_html.py index e508acb3..f78e532f 100644 --- a/tests/test_table_html.py +++ b/tests/test_table_html.py @@ -270,7 +270,8 @@ async def test_empty_search_parameter_gets_removed(ds_client): async def test_searchable_view_persists_fts_table(ds_client): # The search form should persist ?_fts_table as a hidden field response = await ds_client.get( - "/fixtures/searchable_view?_fts_table=searchable_fts&_fts_pk=pk" + "/fixtures/searchable_view_configured_by_metadata" + "?_fts_table=searchable_fts&_fts_pk=pk" ) inputs = Soup(response.text, "html.parser").find("form").find_all("input") hiddens = [i for i in inputs if i["type"] == "hidden"]