Módulo 5: Guardrails y seguridad
La allowlist de sentencias: solo `SELECT`/`WITH`, una sola
Descripción
La cápsula anterior blindó la conexión: aunque llegue un DELETE, el motor lo rechaza. Esta cápsula construye la capa que actúa antes, en el nivel del texto: la allowlist de sentencias, un filtro que mira la cadena del SQL y decide si tiene permiso de ejecutarse sin siquiera tocar la conexión. Es la primera puerta del sistema, y la que da los mensajes de rechazo claros ("sentencia no permitida: empieza con DROP") en vez de un OperationalError seco del motor.
La regla es deliberadamente estricta: se permite exactamente una sentencia, y esa sentencia debe empezar por SELECT o WITH. Todo lo demás se rechaza: DROP, DELETE, UPDATE, INSERT, ALTER, ATTACH, un PRAGMA de escritura, y cualquier intento de colar dos sentencias con un ;. No es una lista de cosas prohibidas (eso sería una blocklist, y verás por qué es peligrosa); es una lista de lo único permitido (una allowlist), y todo lo que no está explícitamente permitido, se niega.
Reusaremos el strip_sql que ya construiste en el Módulo 4 —limpiar comentarios y espacios— y lo convertiremos, de un chequeo de forma (¿es una lectura?), en un guardrail de seguridad con todas sus letras.
Conexión con el módulo
En M4 mirar la primera palabra fue una validación de corrección: un DELETE no es una consulta de lectura. Aquí ese mismo chequeo es seguridad: un DELETE no debe poder ejecutarse. Es la misma línea de código con otra intención y otro respaldo (la conexión de solo lectura de la cápsula 03 detrás). La allowlist y la conexión de solo lectura son dos capas para el mismo peligro: si un caso borde engaña a una, la otra ataja. Esa redundancia es el tema de la cápsula 07.
Analogía: la lista de invitados, no la lista de vetados
Hay dos maneras de controlar quién entra a una fiesta.
La primera es una lista de vetados: "no dejes entrar a Fulano ni a Mengano". El problema es evidente: solo funciona contra los nombres que anticipaste. Aparece alguien que no está en tu lista de vetados —porque no lo conocías, o porque cambió de nombre— y entra. Cada vez que descubres un colado nuevo, agregas su nombre... y siempre vas un paso atrás.
La segunda es una lista de invitados: "solo dejan entrar a estos tres nombres, nadie más". Ahora no importa cuántos desconocidos aparezcan ni cómo se llamen: si no están en la lista, no entran. No tienes que anticipar a cada colado posible; anticipaste a los permitidos, que son pocos y conocidos.
En seguridad, la lista de vetados se llama blocklist y la lista de invitados allowlist. Para el SQL de un LLM, la allowlist es la única defensa sensata: los verbos peligrosos son muchos y varían entre dialectos (DROP, DELETE, TRUNCATE, REPLACE, ATTACH, VACUUM…), pero los verbos permitidos son solo dos, SELECT y WITH. Enumeras lo bueno, que es corto, y niegas todo lo demás por defecto.
Por qué una blocklist es un error
Antes de construir la allowlist, veamos por qué su alternativa —bloquear una lista de verbos malos— falla. Supón que escribes:
# ANTIPATRÓN: blocklist. NO hagas esto.
BLOCKED = {"DROP", "DELETE", "UPDATE", "INSERT", "ALTER", "TRUNCATE"}
def is_dangerous_blocklist(sql):
first = sql.strip().split(None, 1)[0].upper()
return first in BLOCKED
Parece razonable, y bloquea los sospechosos obvios. Pero se le escapan cosas, y basta una:
ATTACH DATABASE 'evil.db' AS evil— no está en tu lista, y permite montar otra base de datos: una fuga de datos y una vía de escritura.PRAGMA query_only = OFF— no está en tu lista, y —como viste en la cápsula 03— apaga el guardrail de solo-lectura.REPLACE INTO members …— un alias deINSERT OR REPLACEque no empieza porINSERT; se cuela.VACUUM,REINDEX, y cualquier verbo que exista en tu dialecto o en la próxima versión del motor y que no hayas anticipado.
Cada uno de estos es un colado que tu lista de vetados no vio venir. Y solo hace falta que uno se cuele para que la defensa caiga. Ese es el defecto estructural de la blocklist: te obliga a conocer de antemano todo lo malo, y lo malo es abierto e ilimitado. La allowlist invierte la carga: enumeras lo bueno (dos verbos) y niegas el resto por defecto, sin tener que conocerlo.
Construir la allowlist
La allowlist reutiliza el strip_sql de M4 (limpiar comentarios y espacios, para que un modelo que antepone -- explicación no engañe el chequeo) y aplica tres reglas: no vacía, una sola sentencia, empieza por SELECT/WITH.
import re
def strip_sql(sql):
"""(De M4) Quita comentarios de línea y de bloque, y espacios de los extremos."""
sql = re.sub(r"--[^\n]*", "", sql)
sql = re.sub(r"/\*.*?\*/", "", sql, flags=re.DOTALL)
return sql.strip()
ALLOWED_FIRST = ("SELECT", "WITH")
def check_allowlist(sql):
"""Permite UNA sentencia que empiece por SELECT o WITH. Devuelve (ok, motivo)."""
clean = strip_sql(sql)
if not clean:
return False, "consulta vacía"
body = clean.rstrip(";").strip() # quita un ; final legítimo
if ";" in body: # ¿queda un ; en el cuerpo? -> 2 sentencias
return False, "más de una sentencia (posible inyección)"
first = body.split(None, 1)[0].upper() # primera palabra, en mayúsculas
if first not in ALLOWED_FIRST:
return False, f"sentencia no permitida: empieza con {first}"
return True, "SELECT de una sola sentencia"
Tres reglas, en orden barato-a-menos-barato: cadena no vacía, una sola sentencia, verbo permitido. Fíjate en que la allowlist no consulta la base de datos —solo mira el texto—: es la capa más barata y la primera en actuar. Probémosla con un abanico de entradas, buenas y malas:
tests = [
"SELECT name FROM rooms", # lectura simple
"WITH c AS (SELECT * FROM rooms) SELECT name FROM c", # CTE, también lectura
"DELETE FROM bookings", # escritura
"DROP TABLE rooms", # DDL destructivo
"UPDATE rooms SET name='x'", # escritura
"SELECT 1; DROP TABLE rooms", # multi-sentencia (inyección)
"SELECT * FROM rooms; DELETE FROM bookings", # multi-sentencia
"PRAGMA query_only = OFF", # apagar el guardrail
"ATTACH DATABASE 'evil.db' AS evil", # montar otra base
"-- comentario\nSELECT 1", # comentario + lectura legítima
]
for s in tests:
ok, why = check_allowlist(s)
tag = "PASA " if ok else "RECHAZO"
print(f"[{tag}] {why:45} <- {s[:40]!r}")
Qué esperar:
[PASA ] SELECT de una sola sentencia <- 'SELECT name FROM rooms'
[PASA ] SELECT de una sola sentencia <- 'WITH c AS (SELECT * FROM rooms) SELECT n'
[RECHAZO] sentencia no permitida: empieza con DELETE <- 'DELETE FROM bookings'
[RECHAZO] sentencia no permitida: empieza con DROP <- 'DROP TABLE rooms'
[RECHAZO] sentencia no permitida: empieza con UPDATE <- "UPDATE rooms SET name='x'"
[RECHAZO] más de una sentencia (posible inyección) <- 'SELECT 1; DROP TABLE rooms'
[RECHAZO] más de una sentencia (posible inyección) <- 'SELECT * FROM rooms; DELETE FROM booking'
[RECHAZO] sentencia no permitida: empieza con PRAGMA <- 'PRAGMA query_only = OFF'
[RECHAZO] sentencia no permitida: empieza con ATTACH <- "ATTACH DATABASE 'evil.db' AS evil"
[PASA ] SELECT de una sola sentencia <- '-- comentario\nSELECT 1'
Diez entradas, diez veredictos. Las dos lecturas (SELECT y WITH) pasan. Todo lo demás se rechaza —y fíjate en lo que la allowlist atajó que una blocklist ingenua habría dejado pasar: el PRAGMA query_only = OFF y el ATTACH. No tuvimos que "conocerlos": no empiezan por SELECT/WITH, así que caen por defecto. La última entrada es la más instructiva: un SELECT con un comentario antepuesto pasa, porque strip_sql limpió el comentario antes de mirar la primera palabra. La allowlist es estricta con lo peligroso y justa con lo legítimo.
Las dos defensas de la multi-sentencia
La regla "una sola sentencia" merece una mirada, porque es la que ataja la inyección clásica de stacked queries —colar un DROP detrás de un SELECT—. La allowlist la caza contando ; en el cuerpo (tras quitar un ; final legítimo). Pero, como recordarás de M4, hay una segunda red: el método con.execute() de sqlite3 se niega a correr más de una sentencia. Verifiquémoslo de nuevo, ahora como guardrail de seguridad:
import sqlite3
con = sqlite3.connect("reservo.db")
try:
con.execute("SELECT 1; DROP TABLE rooms")
except sqlite3.Error as e:
print(f"{type(e).__name__}: {e}")
con.close()
Qué esperar:
ProgrammingError: You can only execute one statement at a time.
Dos capas para el mismo peligro: la allowlist rechaza la multi-sentencia por el texto (con un mensaje claro), y execute() se niega a correrla aunque el chequeo del ; se la perdiera (por ejemplo, un ; escondido en un literal). Es executescript() —no execute()— el que corre varias sentencias, y en un ejecutor seguro nunca se usa executescript con SQL del modelo. Guardar execute() como el único punto de ejecución es en sí mismo un guardrail.
La allowlist va antes que la conexión: por qué el orden importa
La allowlist actúa en el texto, antes de que el SQL toque la base de datos. La conexión de solo lectura (cápsula 03) actúa en el motor, cuando el SQL ya llegó. ¿Por qué tener las dos, si la conexión de solo lectura ya bloquea las escrituras?
Por tres razones concretas:
- Mensajes claros. La allowlist dice "sentencia no permitida: empieza con
DROP" —accionable, se puede loggear y hasta devolver al modelo en el loop de corrección—. La conexión de solo lectura diceattempt to write a readonly database, un error de motor más opaco. - Cosas que la conexión no cubre.
mode=robloquea escrituras, pero una multi-sentencia (SELECT …; SELECT …) o unPRAGMAde lectura no son escrituras, y aun así no quieres ejecutarlos. La allowlist los ataja;mode=rono. - Barato antes que caro. Rechazar en el texto no gasta una conexión ni un ciclo del motor. Filtras lo obviamente malo antes de invertir recursos en abrir y ejecutar.
Y a la inversa, la conexión de solo lectura cubre lo que a la allowlist se le pudiera escapar (un caso borde del parseo del ;, un verbo raro que empiece por algo inesperado). Ninguna es suficiente sola; juntas, cada una tapa el hueco de la otra. Ese es, otra vez, el principio del módulo.
¿Y si quiero permitir algo más que lecturas?
A veces un asistente legítimamente necesita escribir —registrar un dato, marcar algo—. La allowlist no te lo prohíbe filosóficamente; te obliga a ser explícito. Amplías el conjunto permitido a conciencia, verbo por verbo, sabiendo lo que abres:
def check_allowlist_rw(sql, allowed=("SELECT", "WITH", "INSERT")):
clean = strip_sql(sql)
if not clean:
return False, "consulta vacía"
body = clean.rstrip(";").strip()
if ";" in body:
return False, "más de una sentencia"
first = body.split(None, 1)[0].upper()
if first not in allowed:
return False, f"verbo no permitido: {first}"
return True, f"permitido: {first}"
print(check_allowlist_rw("INSERT INTO members(name,tier) VALUES ('X','pro')"))
print(check_allowlist_rw("DELETE FROM members WHERE id=1"))
Qué esperar:
(True, 'permitido: INSERT')
(False, 'verbo no permitido: DELETE')
El punto es que ampliar es una decisión explícita y acotada (agregas INSERT al conjunto, no todo lo demás), y si permites escritura debes quitar el mode=ro/query_only —lo cual reintroduce riesgo que debes compensar con otras medidas (mínimo privilegio por tabla, validación de los valores)—. Para un asistente de consultas, el conjunto correcto es el mínimo: solo SELECT y WITH. Cuanto más corta la allowlist, más pequeña la superficie de ataque.
Errores comunes
-
Usar una blocklist. Enumerar verbos prohibidos (
DROP,DELETE…) siempre deja huecos:ATTACH,PRAGMA,REPLACE,VACUUM, o el próximo verbo que no anticipaste. La allowlist enumera lo permitido (dos verbos) y niega el resto por defecto. Es la única opción robusta. -
Olvidar limpiar comentarios antes de mirar la primera palabra. Un modelo que antepone
-- explicacióno/* … */haría que la "primera palabra" sea el comentario, y rechazarías unSELECTlegítimo.strip_sql(de M4) va antes del chequeo. -
Olvidar
WITH. Una CTE (WITH … SELECT) es una lectura perfectamente válida y común. Si la allowlist solo aceptaSELECT, rechazas consultas legítimas. Ambos verbos entran. -
Confiar solo en el conteo de
;. Es pragmático y ataja la mayoría de las multi-sentencia, pero un;dentro de un literal de texto lo puede engañar. Por eso la segunda red escon.execute(), que se niega a correr dos sentencias, y nunca usarexecutescriptcon SQL del modelo. -
Creer que la allowlist reemplaza a la conexión de solo lectura. No: se complementan. La allowlist actúa en el texto (mensajes claros, ataja multi-sentencia y
PRAGMA); la conexión de solo lectura actúa en el motor (garantía dura contra escritura). Si una falla, la otra ataja.
Ejercicios
Ejercicio 1: El verbo en minúsculas y con espacios (Fácil)
¿La allowlist acepta " select name from rooms " (minúsculas y con espacios sobrantes)? ¿Y "\n\nSELECT 1"? Ejecútalos y explica por qué el chequeo no se deja engañar por mayúsculas/minúsculas ni por espacios.
Ver solución
print(check_allowlist(" select name from rooms "))
print(check_allowlist("\n\nSELECT 1"))
Salida esperada:
(True, 'SELECT de una sola sentencia')
(True, 'SELECT de una sola sentencia')
Explicación: strip_sql quita los espacios y saltos de línea de los extremos, y el chequeo compara la primera palabra en mayúsculas (.upper()), así que select, SELECT y Select son equivalentes. Un guardrail no puede depender de que el modelo escriba en mayúsculas o sin espacios: normalizar antes de comparar es parte de hacerlo robusto.
Ejercicio 2: El PRAGMA que la blocklist no vería (Medio)
Demuestra la superioridad de la allowlist: escribe una blocklist ingenua que bloquee {DROP, DELETE, UPDATE, INSERT} y muestra que deja pasar PRAGMA query_only = OFF y ATTACH DATABASE 'evil.db' AS evil, mientras que la allowlist los rechaza.
Ver solución
BLOCKED = {"DROP", "DELETE", "UPDATE", "INSERT"}
def blocklist_ok(sql):
first = strip_sql(sql).split(None, 1)[0].upper()
return first not in BLOCKED # True = "la deja pasar"
for s in ["PRAGMA query_only = OFF", "ATTACH DATABASE 'evil.db' AS evil"]:
print(f"blocklist deja pasar: {blocklist_ok(s)!s:5} | allowlist: {check_allowlist(s)[1]} <- {s!r}")
Salida esperada:
blocklist deja pasar: True | allowlist: sentencia no permitida: empieza con PRAGMA <- 'PRAGMA query_only = OFF'
blocklist deja pasar: True | allowlist: sentencia no permitida: empieza con ATTACH <- "ATTACH DATABASE 'evil.db' AS evil"
Explicación: la blocklist solo conoce cuatro verbos, así que PRAGMA y ATTACH —igual de peligrosos— se le cuelan. La allowlist no necesita conocerlos: como no empiezan por SELECT/WITH, caen por defecto. Este es, en dos líneas, el argumento entero a favor de la allowlist.
Ejercicio 3: Cazar la inyección de stacked query (Difícil)
Un usuario malicioso logró que el modelo devolviera "SELECT * FROM rooms; DROP TABLE bookings". Demuestra que ese SQL es rechazado por dos capas independientes: (a) la allowlist, por texto; (b) con.execute, por motor. Explica por qué tener las dos importa.
Ver solución
import sqlite3
attack = "SELECT * FROM rooms; DROP TABLE bookings"
# Capa (a): allowlist, en el texto
print("allowlist ->", check_allowlist(attack))
# Capa (b): con.execute se niega a correr dos sentencias
con = sqlite3.connect("reservo.db")
try:
con.execute(attack)
except sqlite3.Error as e:
print("execute ->", f"{type(e).__name__}: {e}")
print("bookings intactas:", con.execute("SELECT COUNT(*) FROM bookings").fetchone()[0])
con.close()
Salida esperada:
allowlist -> (False, 'más de una sentencia (posible inyección)')
execute -> ProgrammingError: You can only execute one statement at a time.
bookings intactas: 23
Explicación: la allowlist lo rechaza primero, por el ; en el cuerpo, con un mensaje claro que dice "posible inyección". Pero incluso si la allowlist tuviera un bug y la dejara pasar, con.execute se niega a correr dos sentencias. Las 23 reservas siguen ahí. Dos capas para el mismo ataque: si una falla, la otra ataja. Ese es el diseño defensivo del módulo, y el tema explícito de la cápsula 07.
Resumen y siguiente paso
- La allowlist actúa en el nivel del texto, antes de tocar la conexión: es la primera puerta y la que da mensajes de rechazo claros.
- La regla es estricta: una sola sentencia, que empiece por
SELECToWITH. Todo lo demás —DROP,DELETE,UPDATE,INSERT,ALTER,ATTACH,PRAGMA, multi-sentencia— se rechaza. - Allowlist, no blocklist. Enumerar lo prohibido siempre deja huecos (
ATTACH,PRAGMA,REPLACE…); enumerar lo permitido (dos verbos) y negar el resto por defecto es la única defensa robusta. - Se reusa el
strip_sqlde M4 (limpiar comentarios/espacios antes de mirar la primera palabra) y se convierte el chequeo de forma en un guardrail de seguridad. - La multi-sentencia tiene dos defensas: el conteo de
;en la allowlist y elcon.executeque se niega a correr dos sentencias. Nunca se usaexecutescriptcon SQL del modelo. - Allowlist (texto) y conexión de solo lectura (motor) se complementan: mensajes claros y ataje temprano vs garantía dura de no escritura.
Siguiente cápsula: Límites de recursos — Un SELECT legítimo también puede hacer daño si devuelve millones de filas o corre para siempre. Verás cómo forzar un LIMIT envolviendo la consulta, matar una consulta lenta con un timeout vía set_progress_handler (ejecutado, abortando una consulta pesada), y topar las filas devueltas.
Recursos adicionales
- OWASP — Input Validation Cheat Sheet (Allow-list vs Deny-list) — Por qué enumerar lo permitido (allowlist) es más robusto que enumerar lo prohibido (blocklist).
- SQLite — Query Language:
SELECTyWITH(CTE) — Las dos únicas formas de lectura que la allowlist permite. - Python —
sqlite3.Connection.execute— Ejecuta una sola sentencia; la segunda red contra la multi-sentencia. Compáralo conexecutescript, que sí corre varias (y que nunca se usa con SQL del modelo). - OWASP — SQL Injection (Stacked Queries) — La inyección de "consultas apiladas" (
SELECT …; DROP …) que la regla de una-sola-sentencia ataja. - SQLite —
ATTACH DATABASE— Un ejemplo del tipo de sentencia que una blocklist ingenua no anticipa y la allowlist niega por defecto.