From 40400c0b88caf11abcab8c1814f41c4fcf534dee Mon Sep 17 00:00:00 2001 From: Egutierrez Date: Sat, 13 Jun 2026 01:21:01 +0200 Subject: [PATCH] fix(security): duckdb_query_readonly sandbox por defecto (enable_external_access=false) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CRÍTICO: read_only=True protege la base de datos pero NO el sistema de ficheros. Un SELECT con read_csv/read_blob/glob/COPY...TO podía leer ficheros arbitrarios (claves SSH) o escribirlos (camino a RCE). Añadido parámetro sandbox (default True) que abre la conexión con enable_external_access=false, bloqueando todo acceso a FS/red desde la query. Los SELECT normales sobre tablas siguen funcionando. Único consumidor (osint_db /api/query) queda protegido sin cambios. Tests nuevos: sandbox bloquea read_csv; sandbox=False lo permite. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../functions/infra/duckdb_query_readonly.py | 111 ++++++++++++++++ .../infra/duckdb_query_readonly_test.py | 119 ++++++++++++++++++ 2 files changed, 230 insertions(+) create mode 100644 python/functions/infra/duckdb_query_readonly.py create mode 100644 python/functions/infra/duckdb_query_readonly_test.py diff --git a/python/functions/infra/duckdb_query_readonly.py b/python/functions/infra/duckdb_query_readonly.py new file mode 100644 index 00000000..1270de31 --- /dev/null +++ b/python/functions/infra/duckdb_query_readonly.py @@ -0,0 +1,111 @@ +"""Ejecuta una query SELECT contra una base DuckDB abierta en modo solo lectura. + +Funcion impura: abre un archivo DuckDB con `duckdb.connect(db_path, read_only=True)`, +de modo que nunca crea ni modifica la base. La conexion se cierra siempre en un +bloque try/finally. Ejecuta el SQL con parametros posicionales (DuckDB usa el +marcador `?`) y devuelve un dict sin lanzar excepciones, siguiendo el estilo del +grupo dav del registry: {status:'ok', ...} en exito y {status:'error', error:str} +en fallo. + +Las filas se devuelven como lista de dicts (un dict por fila, mapeando el nombre +de columna a su valor). El resultado se trunca a max_rows para proteger la memoria +y marca truncated=True si la query producia mas filas. Los valores que no son +JSON-serializables se convierten a una forma serializable: date y datetime a +isoformat(), Decimal a float, bytes a una cadena base64 y UUID a str. +""" + +import base64 +import datetime +import decimal +import uuid + + +def _to_serializable(value): + """Convierte un valor de DuckDB a una forma JSON-serializable. + + date/datetime/time -> isoformat(), Decimal -> float, bytes -> base64 str, + UUID -> str. El resto de valores (int, float, str, bool, None) se devuelven + sin cambios. + """ + if value is None: + return None + if isinstance(value, (datetime.datetime, datetime.date, datetime.time)): + return value.isoformat() + if isinstance(value, decimal.Decimal): + return float(value) + if isinstance(value, (bytes, bytearray, memoryview)): + return base64.b64encode(bytes(value)).decode("ascii") + if isinstance(value, uuid.UUID): + return str(value) + return value + + +def duckdb_query_readonly( + db_path: str, + sql: str, + params: list = None, + max_rows: int = 10000, + sandbox: bool = True, +) -> dict: + """Ejecuta un SELECT contra una base DuckDB en modo solo lectura. + + Args: + db_path: ruta al archivo DuckDB. Debe existir: el modo read_only NO crea + la base. Un path inexistente devuelve {status:'error', ...}. + sql: sentencia SQL a ejecutar. Pensada para SELECT; usa el marcador `?` + para parametros posicionales. + params: lista de parametros posicionales para el SQL. None (default) + significa sin parametros. + max_rows: numero maximo de filas a materializar (default 10000). Si la + query produce mas, se trunca y truncated queda en True. + sandbox: si True (default), abre la conexion con + ``enable_external_access=False``, lo que prohibe a la query acceder al + sistema de ficheros y a la red (``read_csv``/``read_blob``/``glob``/ + ``COPY ... TO``/``httpfs``/``ATTACH`` a paths externos). CRITICO cuando + el SQL viene de un cliente no confiable: ``read_only=True`` solo protege + la base de datos, NO el sistema de ficheros, asi que sin el sandbox un + SELECT malicioso puede leer ficheros arbitrarios (p.ej. claves SSH) o + escribirlos. Ponlo en False solo para usos internos confiables que + necesiten leer CSV/Parquet del disco. + + Returns: + dict. En exito: {status:'ok', columns:[...], rows:[{col:val, ...}, ...], + row_count:int, truncated:bool} donde columns es la lista de nombres de + columna y rows es la lista de filas (cada fila un dict). En error + (sin lanzar): {status:'error', error:str}. + """ + conn = None + try: + config = {"enable_external_access": False} if sandbox else {} + conn = __import__("duckdb").connect( + db_path, read_only=True, config=config + ) + cursor = conn.execute(sql, params if params is not None else []) + + description = cursor.description or [] + columns = [col[0] for col in description] + + # Pedimos una fila de mas que max_rows para detectar truncado sin + # materializar todo el resultado en memoria. + fetched = cursor.fetchmany(max_rows + 1) + truncated = len(fetched) > max_rows + if truncated: + fetched = fetched[:max_rows] + + rows = [ + {columns[i]: _to_serializable(value) for i, value in enumerate(record)} + for record in fetched + ] + + return { + "status": "ok", + "columns": columns, + "rows": rows, + "row_count": len(rows), + "truncated": truncated, + } + except Exception as e: # noqa: BLE001 + return {"status": "error", "error": str(e)} + finally: + if conn is not None: + conn.close() diff --git a/python/functions/infra/duckdb_query_readonly_test.py b/python/functions/infra/duckdb_query_readonly_test.py new file mode 100644 index 00000000..2917bcf0 --- /dev/null +++ b/python/functions/infra/duckdb_query_readonly_test.py @@ -0,0 +1,119 @@ +"""Tests para duckdb_query_readonly.""" + +import datetime +import decimal + +import duckdb +import pytest + +from .duckdb_query_readonly import duckdb_query_readonly + + +@pytest.fixture +def db(tmp_path): + """Crea una base DuckDB temporal con datos de ejemplo y devuelve su path.""" + path = str(tmp_path / "test.duckdb") + con = duckdb.connect(path) + con.execute( + "CREATE TABLE ventas (" + " id INTEGER," + " region VARCHAR," + " total DECIMAL(10,2)," + " fecha DATE" + ")" + ) + con.execute( + "INSERT INTO ventas VALUES " + "(1, 'norte', 120.50, DATE '2026-01-01'), " + "(2, 'sur', 80.00, DATE '2026-01-02'), " + "(3, 'norte', 45.25, DATE '2026-01-03')" + ) + con.close() + return path + + +def test_query_ok_devuelve_filas_como_dicts(db): + res = duckdb_query_readonly(db, "SELECT id, region FROM ventas ORDER BY id") + assert res["status"] == "ok" + assert res["columns"] == ["id", "region"] + assert res["row_count"] == 3 + assert res["truncated"] is False + assert res["rows"][0] == {"id": 1, "region": "norte"} + assert res["rows"][1] == {"id": 2, "region": "sur"} + + +def test_query_con_params_posicionales(db): + res = duckdb_query_readonly( + db, + "SELECT id FROM ventas WHERE region = ? ORDER BY id", + params=["norte"], + ) + assert res["status"] == "ok" + assert res["row_count"] == 2 + assert [row["id"] for row in res["rows"]] == [1, 3] + + +def test_sql_invalido_devuelve_status_error(db): + res = duckdb_query_readonly(db, "SELECT * FROM tabla_que_no_existe") + assert res["status"] == "error" + assert "error" in res + assert isinstance(res["error"], str) and res["error"] + + +def test_db_inexistente_devuelve_status_error(tmp_path): + missing = str(tmp_path / "no_existe.duckdb") + res = duckdb_query_readonly(missing, "SELECT 1") + assert res["status"] == "error" + assert "error" in res + + +def test_truncado_a_max_rows(db): + res = duckdb_query_readonly(db, "SELECT id FROM ventas ORDER BY id", max_rows=2) + assert res["status"] == "ok" + assert res["row_count"] == 2 + assert res["truncated"] is True + assert [row["id"] for row in res["rows"]] == [1, 2] + + +def test_valores_no_serializables_se_convierten(db): + res = duckdb_query_readonly( + db, + "SELECT total, fecha FROM ventas WHERE id = ?", + params=[1], + ) + assert res["status"] == "ok" + row = res["rows"][0] + # Decimal -> float + assert isinstance(row["total"], float) + assert row["total"] == pytest.approx(120.50) + # date -> isoformat str + assert isinstance(row["fecha"], str) + assert row["fecha"] == "2026-01-01" + # Verificamos que NO quedan tipos crudos no serializables. + assert not isinstance(row["total"], decimal.Decimal) + assert not isinstance(row["fecha"], datetime.date) + + +def test_sandbox_por_defecto_bloquea_acceso_a_ficheros(db, tmp_path): + """Por defecto (sandbox=True) la query no puede tocar el sistema de ficheros.""" + csv = tmp_path / "externo.csv" + csv.write_text("a\n1\n") + res = duckdb_query_readonly(db, f"SELECT * FROM read_csv_auto('{csv}')") + assert res["status"] == "error" + assert ( + "access" in res["error"].lower() or "permission" in res["error"].lower() + ) + # El SELECT normal sobre la propia base sigue funcionando con el sandbox. + ok = duckdb_query_readonly(db, "SELECT count(*) AS n FROM ventas") + assert ok["status"] == "ok" and ok["rows"][0]["n"] == 3 + + +def test_sandbox_off_permite_leer_csv_del_disco(db, tmp_path): + """Con sandbox=False (uso interno confiable) sí puede leer ficheros.""" + csv = tmp_path / "externo.csv" + csv.write_text("a\n1\n2\n") + res = duckdb_query_readonly( + db, f"SELECT count(*) AS n FROM read_csv_auto('{csv}')", sandbox=False + ) + assert res["status"] == "ok" + assert res["rows"][0]["n"] == 2