diff --git a/wrolpi/db_query.py b/wrolpi/db_query.py index 3eac0bbe..bf050f02 100644 --- a/wrolpi/db_query.py +++ b/wrolpi/db_query.py @@ -51,8 +51,10 @@ ALLOWED_ACTIONS = { sqlite3.SQLITE_FUNCTION, sqlite3.SQLITE_RECURSIVE, } -# PRAGMAs which only describe the schema. A PRAGMA with an argument (e.g. `PRAGMA x=1`) is refused. +# PRAGMAs which only read, whatever their argument. Every other PRAGMA (e.g. `PRAGMA foreign_keys=OFF`) is refused. ALLOWED_PRAGMAS = { + # FTS5 reads this when it opens a table, through the authorizer, on SQLite 3.46 (Debian 13). + 'data_version', 'foreign_key_list', 'index_info', 'index_list', @@ -108,7 +110,7 @@ def _raise_query_error(e: sqlite3.Error): message = str(e) if isinstance(e, sqlite3.OperationalError) and message == 'interrupted': message = f'Query exceeded the {QUERY_TIMEOUT:g} second time limit' - elif 'not authorized' in message: + elif 'not authorized' in message or 'authorization denied' in message: message = 'Only read-only queries (SELECT) are allowed' elif 'too big' in message: message = f'A value is too large (over {MAX_VALUE_LENGTH} bytes)' diff --git a/wrolpi/test/test_db_query.py b/wrolpi/test/test_db_query.py index 4bc92cf5..0dd59880 100644 --- a/wrolpi/test/test_db_query.py +++ b/wrolpi/test/test_db_query.py @@ -1,4 +1,5 @@ import json +import sqlite3 from http import HTTPStatus from unittest import mock @@ -89,6 +90,7 @@ async def test_database_query_select(test_session, async_client, allow_database_ 'PRAGMA foreign_keys=OFF', 'BEGIN', 'VACUUM', + "VACUUM INTO 'copy.db'", "SELECT load_extension('evil')", "SELECT 1; DELETE FROM tag", ]) @@ -100,6 +102,8 @@ async def test_database_query_refuses_writes(test_session, async_client, allow_d request, response = await post_query(async_client, sql) assert response.status_code == HTTPStatus.BAD_REQUEST, response.json assert response.json['code'] == 'INVALID_DATABASE_QUERY' + if ';' not in sql: + assert response.json['error'] == 'Only read-only queries (SELECT) are allowed' request, response = await post_query(async_client, 'SELECT name FROM tag') assert response.json['rows'] == [['one']] @@ -210,3 +214,19 @@ async def test_database_query_memory_limits(test_session, async_client, allow_da assert response.status_code == HTTPStatus.OK, response.json assert response.json['row_count'] == 3 assert response.json['truncated'] is True + + +@pytest.mark.asyncio +async def test_database_query_full_text_search(test_session, async_client, allow_database_query, video_factory): + """FTS5 tables can be searched. FTS5 reads `PRAGMA data_version` through the authorizer on some SQLite + versions (3.46), so that PRAGMA must be allowed even where this SQLite does not ask.""" + video_factory(title='Catching rain water') + test_session.commit() + + sql = "SELECT fg.title FROM file_group_fts JOIN file_group fg ON fg.id = file_group_fts.rowid" \ + " WHERE file_group_fts MATCH 'water'" + request, response = await post_query(async_client, sql) + assert response.status_code == HTTPStatus.OK, response.json + assert response.json['rows'] == [['Catching rain water']] + + assert db_query._authorizer(sqlite3.SQLITE_PRAGMA, 'data_version', None, 'main', None) == sqlite3.SQLITE_OK