fix(security): duckdb_query_readonly sandbox por defecto (enable_external_access=false)
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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()
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user