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:
2026-06-13 01:21:01 +02:00
parent 236a4740b0
commit 40400c0b88
2 changed files with 230 additions and 0 deletions
@@ -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()