La inyección SQL es un bug de concatenación de cadenas
La inyección SQL en SQLite ocurre cuando la entrada del usuario termina formando parte del texto SQL que tu base de datos interpreta. En el momento en que esa frontera se difumina —cuando un valor que tecleó el usuario pasa a ser sintaxis que la base de datos ejecuta— ese usuario puede hacer todo lo que tú puedas hacer.
Este es el antipatrón clásico, en pseudocódigo que cualquier lenguaje puede generar:
-- NO HAGAS ESTO
query = "SELECT * FROM users WHERE name = '" + user_input + "'"
Si user_input es Ada, obtienes una búsqueda normal. Pero si user_input es ' OR 1=1 --, lo que se ejecuta es:
SELECT * FROM users WHERE name = '' OR 1=1 --'
El -- comenta la comilla final, OR 1=1 hace match con todas las filas y el atacante acaba de llevarse tu tabla de usuarios. Las versiones peores encadenan un ; y una segunda sentencia para borrar tablas, filtrar datos o crear una cuenta de administrador nueva.
La vulnerabilidad no está en SQLite, sino en el código que construyó esa cadena.
Consultas parametrizadas: la solución de verdad
Una consulta parametrizada separa el texto SQL de los valores. El SQL lleva marcadores —? o :nombre— y los valores los pasas aparte. SQLite parsea y compila el SQL una sola vez, y luego enlaza tus valores al plan ya compilado. Los valores nunca pueden convertirse en SQL.
Veamos cómo ejecutar de forma segura una consulta que parecía vulnerable:
En la shell de SQLite escribes el valor directamente, pero en el código de tu aplicación el equivalente se ve así (con el driver sqlite3 de Python):
# Python — parametrizado, seguro
cursor.execute("SELECT * FROM users WHERE name = ?", (user_input,))
Pasa el SQL y la tupla de valores como dos argumentos separados. El driver los envía a SQLite por canales distintos. Aunque user_input valga ' OR 1=1 --, SQLite va a buscar un usuario que se llame literalmente ' OR 1=1 -- y, obviamente, no lo encuentra.
Qué significa realmente "seguro" aquí
La seguridad no viene de hacer pattern-matching ni de escapar caracteres. Es algo estructural. SQLite compila la sentencia a una forma interna antes siquiera de ver tu valor:
-- La sentencia compilada tiene un hueco, no una cadena.
SELECT * FROM users WHERE name = ?
^
hueco para parámetro
Cuando enlazas un valor, este entra en ese hueco como un dato tipado: TEXT, INTEGER, BLOB, lo que toque. SQLite nunca lo vuelve a interpretar como SQL. No hay sintaxis posible para que el atacante inyecte nada, porque el parser ya terminó su trabajo.
Por eso las consultas parametrizadas en SQLite son fiables de una forma que el escapado jamás logra. Escapar intenta limpiar caracteres peligrosos de una cadena. Enlazar parámetros, en cambio, evita construir esa cadena peligrosa desde el principio.
No caigas en la tentación del formateo de cadenas
Cada lenguaje tiene su atajo seductor —los f-strings de Python, los template literals de JavaScript, String.format en Java— y todos son una bomba de relojería cuando se trata de SQL.
# NO HACER — la f-string interpola el valor dentro del texto SQL
cursor.execute(f"SELECT * FROM users WHERE name = '{user_input}'")
# NO HACER — mismo problema, formateo con %
cursor.execute("SELECT * FROM users WHERE name = '%s'" % user_input)
# HACER — marcador de posición + argumento de valores
cursor.execute("SELECT * FROM users WHERE name = ?", (user_input,))
Las dos primeras meten la entrada del usuario dentro de la cadena SQL antes de que el driver siquiera la vea. Cuando SQLite recibe la consulta, el daño ya está hecho. La tercera mantiene el SQL y el valor en carriles separados.
La regla es mecánica: si alguna vez te ves armando una cadena SQL con +, f-strings, format o template literals justo donde va un valor, detente y usa un marcador en su lugar.
Varios parámetros y marcadores con nombre
Las consultas reales casi siempre llevan más de un valor. SQLite admite tanto marcadores posicionales ? como marcadores con nombre :nombre:
En el código de la aplicación esto se traduce a:
# Posicional
cursor.execute(
"SELECT * FROM orders WHERE customer = ? AND status = ?",
("Ada", "paid"),
)
# Con nombre — más claro cuando hay varios parámetros
cursor.execute(
"SELECT * FROM orders WHERE total > :min_total AND status = :status",
{"min_total": 50, "status": "paid"},
)
Los parámetros con nombre escalan mucho mejor. Cuando ya pasas de tres o cuatro valores, ?, ?, ?, ? se convierte en un juego de adivinanza; en cambio, :customer, :total, :status, :created_at se documenta solo.
Los identificadores piden otro enfoque
Los parámetros enlazados solo sirven para valores: lo que va a la derecha de un =, dentro de IN (...) o en VALUES (...). No funcionan para nombres de tablas, nombres de columnas ni palabras clave de SQL como ASC o DESC.
-- Esto NO funciona. El marcador de posición no puede sustituir el nombre de una columna.
SELECT * FROM users ORDER BY ? ASC
Si necesitas un identificador dinámico —por ejemplo, dejar que el usuario elija por qué columna ordenar—, valida primero contra una lista blanca antes de construir el SQL:
# Enfoque de lista de permitidos
ALLOWED_SORT_COLUMNS = {"name", "created_at", "role"}
if sort_column not in ALLOWED_SORT_COLUMNS:
raise ValueError(f"Columna de ordenación no válida: {sort_column}")
query = f"SELECT * FROM users ORDER BY {sort_column} ASC"
cursor.execute(query)
La cadena que envía el usuario se valida contra un conjunto fijo de valores seguros antes de que llegue al SQL. La f-string se puede usar aquí únicamente porque sort_column ya no puede tomar otro valor que no sea uno de esos tres nombres fijos en el código.
Un intento de inyección SQL, neutralizado
Veamos las dos versiones lado a lado con una entrada maliciosa. Para empezar, creamos una pequeña tabla de usuarios:
El formulario vulnerable devuelve todos los usuarios. La versión con consultas parametrizadas busca un usuario que se llame literalmente ' OR 1=1 -- y no devuelve nada. La misma entrada, un resultado totalmente distinto: porque en el segundo caso, ese valor nunca llegó a interpretarse como SQL.
Checklist rápido para prevenir inyección SQL
- Usa marcadores
?o:nombrepara cada valor que venga de fuera de tu código: entrada del usuario, cuerpos de petición, variables de entorno, cualquier cosa que no hayas escrito tú a mano. - Nunca construyas SQL con
+, f-strings niformatcuando ahí vaya un valor. - Para nombres dinámicos de tablas o columnas, valídalos contra una lista blanca fija antes de meterlos en la consulta.
- Confía en el driver. No te inventes tu propia función para escapar comillas. El mecanismo de bind parameters lleva años puliéndose, está más que probado en batalla y lo hace bien.
- Revisa las consultas de tu equipo con una sola pregunta en mente: ¿se está concatenando entrada de usuario dentro del texto SQL? Si la respuesta es sí, corrígelo.
Cuando este hábito se te quede en los dedos, la inyección SQL deja de ser una categoría de bug en la que tengas que pensar.
Siguiente paso: conectar desde aplicaciones
Ya has visto cómo se ve una consulta segura: el marcador dentro del SQL y el valor pasado por separado. En la siguiente página vamos a montar SQLite desde código de aplicación real en Python, Node.js y alguno más, incluyendo el manejo de conexiones y dónde encajan las consultas parametrizadas dentro del flujo típico de una petición.
Preguntas frecuentes
¿SQLite es vulnerable a inyección SQL?
Sí. SQLite es tan vulnerable como cualquier otra base de datos SQL cuando el código de la aplicación arma las consultas concatenando strings. La solución no es una opción de configuración de SQLite, sino la forma en que pasas los valores desde tu aplicación. Usa consultas parametrizadas con marcadores ? o :nombre y deja que el driver se encargue del resto.
¿Cómo evitan la inyección SQL las consultas parametrizadas?
Cuando usas marcadores como ?, SQLite primero analiza y compila la consulta, y luego enlaza tus valores en huecos dentro de la sentencia ya compilada. Esos valores nunca pueden convertirse en sintaxis SQL: se tratan como datos puros y se acabó. No hay ningún string del que un atacante pueda escapar.
¿No basta con escapar las comillas en lo que escribe el usuario?
No. El escapado manual es frágil: tarde o temprano se te escapa un caso raro (comillas Unicode, trucos de codificación, marcadores de comentario) y publicas una vulnerabilidad. Los drivers ofrecen parámetros ? y :nombre precisamente para que no tengas que pensar en escapar nada. Úsalos siempre, incluso con valores que crees que son seguros.
¿Y qué pasa con nombres de tablas o columnas que vienen del usuario?
Los parámetros enlazados solo sirven para valores, no para identificadores. Si el nombre de una tabla o columna tiene que ser dinámico, valídalo contra una lista blanca de nombres conocidos antes de meterlo en el SQL. Nunca pases un identificador del usuario directamente con formateo de strings.