Seguridad
Cuando se trabaja con bases de datos desde un backend, uno de los pilares esenciales es la seguridad. No basta con que tus consultas funcionen; deben ser seguras, predecibles y resistentes a manipulaciones externas.
Todas las aplicaciones que reciben datos del usuario, ya sea desde una API, un formulario o cualquier otro medio, corren el riesgo de sufrir un problema muy conocido: la inyección SQL.
La buena noticia es que SQLite tiene un sistema sencillo pero potente que, si se usa bien, elimina casi por completo los riesgos asociados. Para aprovecharlo, necesitas entender qué ocurre cuando concatenas texto para construir una consulta, cómo funcionan los placeholders y por qué las consultas preparadas son la base de un backend seguro en Node.js.
Qué es una inyección SQL y por qué ocurre
1. Montamos un ejemplo real en DB Browser: users y orders
Primero, crea una base nueva en DB Browser, por ejemplo seguridad_demo.db, y ve a la pestaña Execute SQL.
Activa las claves foráneas (buena práctica, aunque no es clave para la inyección):
PRAGMA foreign_keys = ON;
1.1 Crear la tabla users
CREATE TABLE users (
id INTEGER PRIMARY KEY,
email TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
role TEXT NOT NULL -- por ejemplo 'user' o 'admin'
);
1.2 Crear la tabla orders
CREATE TABLE orders (
id INTEGER PRIMARY KEY,
user_id INTEGER NOT NULL,
total REAL NOT NULL,
created_at TEXT NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id)
);
1.3 Insertar datos de prueba
Usuarios:
INSERT INTO users (email, name, role) VALUES
('ana@example.com', 'Ana', 'user'),
('luis@example.com', 'Luis', 'user'),
('admin@example.com', 'Admin', 'admin');
Pedidos:
INSERT INTO orders (user_id, total, created_at) VALUES
(1, 120.50, '2023-01-10'),
(1, 89.99, '2023-02-14'),
(2, 230.00, '2023-03-01'),
(3, 999.99, '2023-04-01');
Ahora ya tienes un mini sistema realista:
users: 2 usuarios normales y 1 admin.orders: algunos pedidos repartidos.
Puedes comprobarlo:
SELECT * FROM users;
SELECT * FROM orders;
2. Consulta normal y “bienintencionada”
Imagina que quieres validar el login por email (luego podrías comprobar la contraseña, etc.).
En SQL puro (sin pensar aún en seguridad), podrías hacer:
SELECT *
FROM users
WHERE email = 'ana@example.com';
Si ejecutas eso en DB Browser, verás solo la fila de Ana.
Esta consulta ES correcta cuando tú escribes el email a mano, o cuando el dato viene de una fuente segura.
El problema no está en la consulta en sí, sino en cómo se construye en el backend cuando el valor viene del usuario.
3. Qué hace un backend inseguro: concatenar texto + datos
En un backend inseguro, el programador suele hacer “algo así” (pseudo-código):
// Pseudocódigo en JavaScript
const email = req.body.email; // dato que viene del usuario
const sql = `
SELECT * FROM users
WHERE email = '` + email + `';
`;
// Luego ejecuta esa cadena SQL directamente:
db.query(sql);
Fíjate: se está construyendo una cadena de texto grande que mezcla:
- parte fija:
"SELECT * FROM users WHERE email = '" - parte variable (dato del usuario):
email - cierre de comillas y punto y coma:
"';"
Mientras el usuario ponga un email “normal” (por ejemplo ana@example.com), la consulta resultante es:
SELECT * FROM users
WHERE email = 'ana@example.com';
Todo bien.
4. ¿Qué pasa si el usuario mete algo “malicioso”?
Aquí empieza la inyección SQL.
Supón que en el formulario el usuario escribe como email:
ana@example.com' OR '1'='1
Es decir, no escribe un email real, sino un trozo de SQL que rompe tu consulta.
Veamos qué hace tu código inseguro (concatenación) con este valor:
const email = "ana@example.com' OR '1'='1";
const sql = `
SELECT * FROM users
WHERE email = '` + email + `';
`;
La consulta final que se envía a SQLite será:
SELECT * FROM users
WHERE email = 'ana@example.com' OR '1'='1';
Obsérvalo con calma:
- Se abrió una comilla
'después deemail = - El atacante la cerró con
ana@example.com' - Añadió
OR '1'='1 - Tú cierras la consulta con
';
El resultado es que el WHERE ya no es “email = algo concreto”, sino:
email = 'ana@example.com' OR '1'='1'
La parte OR '1'='1' siempre es verdadera, así que el WHERE es siempre cierto para todas las filas.
Si ahora copias esa consulta en DB Browser y la ejecutas:
SELECT * FROM users
WHERE email = 'ana@example.com' OR '1'='1';
Verás que se devuelven todos los usuarios, incluyendo el admin.
En un backend real eso puede significar:
- Listado completo de usuarios
- Salto de restricciones de acceso
- Filtrado de datos sensibles, etc.
Y todo porque: el dato del usuario ha terminado “inyectado” dentro del código de la consulta.
5. Otro ejemplo con orders: filtrar pedidos por usuario
Imagina ahora un endpoint que lista pedidos de un usuario concreto, recibiendo un user_id por URL.
El programador “ingenuo” hace:
const userId = req.query.user_id; // viene de la URL
const sql = `
SELECT *
FROM orders
WHERE user_id = ` + userId + `;
`;
db.query(sql);
Si el usuario accede a /orders?user_id=1:
userId = "1"- consulta final:
SELECT *
FROM orders
WHERE user_id = 1;
En DB Browser verías solo los pedidos del usuario 1.
Pero si el atacante pone en la URL:
/orders?user_id=1 OR 1=1
Entonces userId = "1 OR 1=1" y tu backend genera:
SELECT *
FROM orders
WHERE user_id = 1 OR 1=1;
Ejecuta esto en DB Browser:
SELECT *
FROM orders
WHERE user_id = 1 OR 1=1;
Verás que ahora te salen todos los pedidos, incluidos los del admin, aunque el usuario “se supone” que solo debería ver los suyos.
De nuevo, el problema no es SQLite, ni la tabla, ni la consulta en abstracto.
El problema es: has mezclado código SQL con texto sin procesar que viene del usuario.
6. La causa real: datos + código en la misma cadena
Ya puedes ver claramente la frase clave:
La causa directa es que los datos se mezclan con el código SQL.
Cuando tú escribes en el backend:
"SELECT * FROM users WHERE email = '" + email + "';"
estás dando al usuario poder sobre: qué datos se filtran, e incluso, sobre la estructura de la consulta.
Si el usuario mete comillas ', operadores (OR, AND), comentarios (--), etc., puede romper tu intención original y convertir una consulta sencilla en algo totalmente distinto.
En casos aún peores, podría intentar inyectar:
'; DROP TABLE users; --
Lo que te daría una consulta del estilo:
SELECT * FROM users WHERE email = 'loquesea';
DROP TABLE users; --';
No hace falta que pruebes esto en DB Browser (o hazlo solo en una base de pruebas de mentira), pero sirve para entender el riesgo: el atacante ya no está solo cambiando filtros, está añadiendo nuevas sentencias SQL.
7. Cómo se evita: placeholders y consultas preparadas
La solución moderna y correcta es:
- Nunca construir la consulta concatenando cadenas.
- Usar placeholders (parámetros) y dejar que la librería los trate como datos, no como código.
Ejemplo en pseudo-código con SQLite (Node.js, por ejemplo):
const email = req.body.email;
// Consulta con placeholder ?
const sql = `
SELECT * FROM users
WHERE email = ?;
`;
// El valor va en un array aparte
db.get(sql, [email]);
Aquí pasan dos cosas importantes:
-
El SQL es fijo:
SELECT * FROM users WHERE email = ?;Esa string nunca cambia por lo que escriba el usuario.
-
El valor de
emailse envía por separado, como dato.La librería lo escapa y lo trata como un literal, no como código.
Si el usuario mete:
ana@example.com' OR '1'='1
la consulta que ejecuta realmente SQLite es conceptualmente:
SELECT * FROM users
WHERE email = 'ana@example.com'' OR ''1''=''1';
Es decir, lo trata como un texto largo (con comillas escapadas), no como fragmento de SQL.
Resultado:
- no se rompe la consulta
- no aparece el
OR '1'='1'como parte del código - simplemente vas a buscar un email raro que seguramente ni existe
La idea es exactamente la misma para el user_id:
const userId = req.query.user_id;
const sql = `
SELECT *
FROM orders
WHERE user_id = ?;
`;
db.all(sql, [userId]);
Por mucho que ponga 1 OR 1=1, el motor tratará ese texto como un número o lo convertirá, pero no ejecutará “OR 1=1” como código.
8. Resumen
- La inyección SQL no es “magia negra”.
- Es el resultado directo de una mala práctica muy concreta:
- crear consultas concatenando cadenas con datos del usuario.
- El atacante aprovecha que tú le pegas su texto sin filtrar a tu SQL:
- cierra comillas,
- añade
OR 1=1, - a veces incluso mete más sentencias.
- En DB Browser puedes ver cómo pasa:
- construyes a mano la consulta “rota”
- la ejecutas
- ves que de repente se devuelven todas las filas o se ejecutan comandos inesperados.
- La forma correcta:
- usar placeholders (
?,:nombre…) - enviar los valores separados del SQL
- dejar que la librería/driver escape y trate esos valores como datos.
- usar placeholders (
Qué son los placeholders y por qué eliminan el problema
Explicación clara con ejemplos reales en DB Browser for SQLite
En el apartado anterior viste cómo ocurre una inyección SQL:
el usuario mete un texto malicioso, el backend lo concatena en una cadena SQL, y el motor lo interpreta como código.
La solución consiste en separar código SQL de datos del usuario, y eso se consigue usando placeholders.
Los placeholders son marcadores dentro de una consulta SQL que representan valores, no instrucciones.
Por lo tanto, por muy malicioso que sea el texto que introduzca el usuario, SQLite lo tratará como un dato literal, nunca como parte de la consulta.
Vamos a verlo todo desde cero, con un ejemplo totalmente real en DB Browser, igual que hicimos con la inyección SQL.
Preparación del ejemplo en DB Browser
Crea una base nueva (por ejemplo seguridad_placeholders.db) y abre Execute SQL.
Activa claves foráneas:
PRAGMA foreign_keys = ON;
Crear tabla users
CREATE TABLE users (
id INTEGER PRIMARY KEY,
email TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
role TEXT NOT NULL
);
Insertar datos reales
INSERT INTO users (email, name, role) VALUES
('ana@example.com', 'Ana', 'user'),
('luis@example.com', 'Luis', 'user'),
('admin@example.com', 'Admin', 'admin');
Comprueba los datos:
SELECT * FROM users;
Ya tenemos una tabla real para probar cómo funcionan los placeholders.
Cómo sería una consulta insegura
Antes de hablar de placeholders, recuerda la consulta insegura típica:
SELECT * FROM users WHERE email = 'ana@example.com';
Si el backend concatena texto y el usuario escribe algo como:
ana@example.com' OR '1'='1
la consulta final se puede convertir en:
SELECT * FROM users WHERE email = 'ana@example.com' OR '1'='1';
Lo cual devuelve todos los usuarios.
Este es el punto exacto que los placeholders corrigen.
Qué es un placeholder
Un placeholder es un hueco en la consulta, un marcador que dice:
Aquí va un valor, pero no sé cuál; lo recibiré más tarde.
Y SQLite garantiza que ese valor siempre se interpreta como dato, nunca como SQL ejecutable.
Existen dos tipos:
- Posicionales:
?,?1,?2 - Nombrados:
:email,:id,@name,$price
Veamos cada uno con ejemplos prácticos.
Placeholders posicionales: el tipo más simple
(Fáciles de ver, fáciles de usar)
Imagina que quieres obtener un usuario por email.
Con placeholder posicional, la consulta se escribe así:
SELECT *
FROM users
WHERE email = ?;
Esto significa:
?es un valor que vendrá después, no está dentro de la consulta.
En un backend (Node.js, Python, PHP, etc.) se llama así:
db.get("SELECT * FROM users WHERE email = ?", [emailDelUsuario]);
Es decir:
- La consulta no cambia, siempre es la misma string.
- Los valores del usuario van separados, en un array.
¿Qué ocurre si el usuario mete un texto malicioso?
Supón que el usuario escribe como email:
ana@example.com' OR '1'='1
El backend lo enviará así:
Query:
SELECT * FROM users WHERE email = ?;
Parámetro:
["ana@example.com' OR '1'='1"]
SQLite convierte el parámetro en un literal seguro:
email = 'ana@example.com'' OR ''1''=''1'
Fíjate en las comillas duplicadas que escapan el contenido.
Esta versión NO rompe la consulta y NO ejecuta ningún OR.
Si pruebas esta consulta directamente en DB Browser:
SELECT *
FROM users
WHERE email = 'ana@example.com'' OR ''1''=''1';
Verás que no devuelve nada, porque no hay ningún email con ese texto extraño.
Ese es el poder de los placeholders:
desactivan cualquier intento de convertir datos en código.
Ejemplo con varios placeholders posicionales
SELECT *
FROM users
WHERE email = ? AND role = ?;
Los parámetros deben enviarse en orden:
db.get(sql, [emailUsuario, rolUsuario]);
En DB Browser la prueba es conceptual (no puedes pasar parámetros directamente), pero puedes simularlo sustituyendo los valores por literales escapados.
Placeholders con nombre: más legibles
Son ideales cuando tu consulta tiene muchos parámetros:
SELECT *
FROM users
WHERE email = :email;
En el backend:
db.get(sql, { email: emailDelUsuario });
Y también pueden repetirse:
SELECT *
FROM logs
WHERE user_id = :uid
OR admin_id = :uid;
SQLite asigna el mismo valor a ambos lugares.
Esto evita errores y hace la consulta más clara.
Veámoslo con un ejemplo completo de orders
Vamos a añadir ahora una tabla orders en DB Browser para que puedas ver cómo funcionan los placeholders con números también.
Crear tabla orders
CREATE TABLE IF not Exists orders (
id INTEGER PRIMARY KEY,
user_id INTEGER NOT NULL,
total REAL NOT NULL,
created_at TEXT NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id)
);
Insertar datos de prueba
INSERT INTO orders (user_id, total, created_at) VALUES
(1, 120.50, '2023-01-10'),
(1, 89.99, '2023-02-14'),
(2, 230.00, '2023-03-01'),
(3, 999.99, '2023-04-01');
Consulta insegura típica
Un backend inseguro haría:
SELECT *
FROM orders
WHERE user_id = 1;
Pero si el atacante pasa en la URL:
1 OR 1=1
la consulta se convierte en:
SELECT *
FROM orders
WHERE user_id = 1 OR 1=1;
Prúebalo en DB Browser:
SELECT *
FROM orders
WHERE user_id = 1 OR 1=1;
Devuelve todos los pedidos, incluido el del admin.
La misma consulta con placeholder posicional
SELECT *
FROM orders
WHERE user_id = ?;
Parámetro que introduce el usuario:
["1 OR 1=1"]
SQLite lo interpreta como un literal:
user_id = '1 OR 1=1'
Es un texto, NO una condición lógica.
No coincide con nada y no devuelve pedidos de ningún usuario.
Ya no puede transformar tu SQL en otra cosa.
Resumen global para recordar siempre
- Los placeholders separan consultas SQL de datos del usuario.
- Los datos del usuario se tratan como valores, no como código.
- Ningún OR, AND, comentario, comilla o sentencia maliciosa puede ejecutarse.
- Este mecanismo elimina el 99% de todas las inyecciones SQL.
- Funcionan con todos los drivers modernos de SQLite.
- Hay dos tipos:
- posicionales:
?,?1,?2 - nombrados:
:email,:uid, etc.
- posicionales:
Consultas preparadas: por qué son importantes
Antes de empezar, recuerda lo que ya hicimos con los placeholders.
Ahora vamos a ampliar esa idea explicando qué es una consulta preparada y por qué mejora seguridad y rendimiento.
Preparación del escenario en DB Browser
Usaremos la base seguridad_placeholders.db que creamos antes.
Si no la tienes, créala de nuevo:
PRAGMA foreign_keys = ON;
Crear tabla users
CREATE TABLE users (
id INTEGER PRIMARY KEY,
email TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
role TEXT NOT NULL
);
Insertar datos
INSERT INTO users (email, name, role) VALUES
('ana@example.com', 'Ana', 'user'),
('luis@example.com', 'Luis', 'user'),
('admin@example.com', 'Admin', 'admin');
Comprueba:
SELECT * FROM users;
Qué es exactamente una consulta preparada
Una consulta preparada (prepared statement) es una consulta SQL que el motor:
- Analiza
- Valida
- Genera un plan de ejecución interno
Pero no la ejecuta todavía.
Después, la misma consulta puede ejecutarse muchas veces, cambiando solo los parámetros:
- una vez con
email = "uno@example.com" - otra con
email = "dos@example.com" - otra con
email = "tres@example.com"
La consulta es la misma, solo cambian los datos.
Eso significa:
- El motor no vuelve a analizar la consulta.
- Se ahorra tiempo (mejor rendimiento).
- Nunca mezcla datos con código (mejor seguridad).
Por qué esto mejora la seguridad
Cuando una consulta se prepara, el motor decide antes de recibir ningún valor cuál es la estructura de la consulta:
SELECT * FROM users WHERE email = :email;
En ese momento, SQLite ya sabe:
- qué tablas se usarán
- qué columnas
- qué operador (
=) - qué tipo de valor se espera
Y, lo más importante:
- Los valores posteriores ya no pueden modificar la estructura del SQL.
Es decir:
- El atacante ya no puede cerrar comillas
- Ni meter
OR 1=1 - Ni cadenas como
'; DROP TABLE users; --
Todo es tratado como texto literal.
Por qué mejora el rendimiento
Cada vez que ejecutas una consulta mediante concatenación, SQLite debe:
- Leer el SQL completo.
- Analizarlo.
- Convertirlo en instrucciones internas.
- Construir un plan de ejecución.
Cuando usas una consulta preparada:
- El análisis y la planificación se hace una sola vez.
- Las ejecuciones posteriores reutilizan ese plan sin reprocesarlo.
Esto es especialmente importante cuando un backend hace:
- búsquedas repetitivas
- operaciones dentro de bucles
- miles de inserciones
- filtros con parámetros
APIs modernas como better-sqlite3 trabajan así casi siempre por defecto.
Ejemplo práctico paso a paso en DB Browser
Aunque DB Browser no permite preparar consultas con parámetros de forma interactiva como un backend, podemos simular el proceso para entenderlo.
3.1 Simulación del proceso “prepare” (conceptual)
Consulta preparada:
SELECT * FROM users WHERE email = :email;
Ejecuciones:
:email = "ana@example.com"
:email = "luis@example.com"
:email = "admin@example.com"
SQLite reutiliza la misma estructura.
En el backend esto se traduce en:
const stmt = db.prepare("SELECT * FROM users WHERE email = :email");
stmt.get({ email: "ana@example.com" });
stmt.get({ email: "luis@example.com" });
stmt.get({ email: "admin@example.com" });
Pero lo importante es entender qué gana SQLite:
- Una sola consulta analizada
- Múltiples ejecuciones rápidas
- Ninguna posibilidad de inyección
Comparación: concatenación insegura vs consulta preparada
Caso inseguro
Imagina el siguiente pseudocódigo:
"SELECT * FROM users WHERE email = '" + userEmail + "';";
Si el usuario introduce:
test@example.com'; DROP TABLE users; --
la consulta final podría quedar:
SELECT * FROM users WHERE email = 'test@example.com';
DROP TABLE users; --';
No ejecutes este ejemplo en tu base real, pero entiendes el problema:
- Si el driver permite múltiples sentencias, puede ejecutar
DROP TABLE users. - Y, desde luego, puede romper tu consulta original.
Esto es consecuencia directa de mezclar código SQL y datos.
Caso seguro con consulta preparada + placeholder
Consulta:
SELECT * FROM users WHERE email = ?;
Parámetro:
test@example.com'; DROP TABLE users; --
SQLite lo tratará como:
"test@example.com''; DROP TABLE users; --"
Lo convierte en texto literal, nunca en código ejecutable.
La consulta que realmente evalúa SQLite es:
SELECT * FROM users
WHERE email = 'test@example.com''; DROP TABLE users; --';
Solo está buscando un email raro que no existe.
Placeholders en operaciones de escritura
(INSERT, UPDATE, DELETE)
Todo lo que hemos dicho no se aplica solo a SELECT.
También protege operaciones que modifican datos, donde los ataques pueden ser aún más graves.
INSERT seguro
INSERT INTO users (email, name, role)
VALUES (?, ?, ?);
UPDATE seguro
UPDATE users
SET name = :name
WHERE id = :id;
DELETE seguro
DELETE FROM users
WHERE email = ?;
Regla simple:
Si vas a meter valores de usuario en una consulta, usa placeholders siempre.
Nunca construyas:
"... WHERE id = " + id
porque abre la puerta a ataques.
Buenas prácticas esenciales
Para asegurar un backend confiable:
- Usa siempre placeholders (posicionales o nombrados).
- No construyas cadenas SQL dinámicas con datos del usuario.
- Evita statements formados con concatenación de texto.
- En operaciones repetidas, usa consultas preparadas.
- Dentro de Node.js, pasa los parámetros siempre como arrays u objetos.
- Si necesitas construir SQL dinámico, produce solo listas de columnas o cláusulas seguras, nunca valores sin parametrizar.
Conclusión final del bloque de seguridad
Con este ejercicio guiado paso por paso ya dominas:
- qué es una inyección SQL,
- cómo se provoca,
- cómo se simula en DB Browser,
- cómo se evita usando placeholders,
- qué es una consulta preparada y por qué mejora seguridad y rendimiento,
- cómo funcionan transacciones seguras,
- cómo suenan los ataques típicos en la práctica y cómo se neutralizan.
Ejercicio Práctico: Inyección SQL en Backend Node.js - PARTE 1
ENUNCIADO
Vas a construir un sistema backend para un concesionario de vehículos que permite buscar y filtrar coches por diferentes criterios. Primero crearás una versión VULNERABLE a inyecciones SQL, aprenderás a explotarla, y luego construirás la versión SEGURA.
OBJETIVOS DE APRENDIZAJE
- Entender qué es una inyección SQL y por qué es peligrosa
- Identificar código vulnerable en aplicaciones reales
- Realizar ataques de inyección SQL en un entorno controlado
- Aplicar técnicas de protección mediante consultas parametrizadas
- Comparar el código vulnerable vs. el código seguro
CONCEPTOS PREVIOS
¿Qué es una inyección SQL?
Una inyección SQL ocurre cuando un atacante puede insertar código SQL malicioso dentro de las consultas que tu aplicación ejecuta en la base de datos. Esto sucede cuando concatenas directamente los datos del usuario en las consultas SQL sin validarlos ni sanitizarlos.
Ejemplo simple:
Consulta vulnerable:
const query = "SELECT * FROM vehiculos WHERE marca = '" + marca + "'";
Si un usuario envía como marca: Toyota' OR '1'='1
La consulta resultante será:
SELECT * FROM vehiculos WHERE marca = 'Toyota' OR '1'='1'
Como '1'='1' es siempre verdadero, esta consulta devolverá TODOS los vehículos, no solo los Toyota.
PARTE 1: PREPARACIÓN DEL ENTORNO
Paso 1: Crear la estructura de carpetas
Crea la siguiente estructura de directorios:
concesionario-vulnerable/
├── database/
├── vulnerable/
└── package.json
concesionario-seguro/
├── database/
├── seguro/
└── package.json
Puedes crearla con estos comandos en tu terminal:
mkdir -p concesionario-vulnerable/database
mkdir -p concesionario-vulnerable/vulnerable
mkdir -p concesionario-seguro/database
mkdir -p concesionario-seguro/seguro
Paso 2: Inicializar el proyecto vulnerable
Navega a la carpeta del proyecto vulnerable:
cd concesionario-vulnerable
Crea el archivo package.json:
{
"name": "concesionario-vulnerable",
"version": "1.0.0",
"type": "module",
"description": "Sistema vulnerable a inyección SQL",
"main": "vulnerable/server.js",
"scripts": {
"start": "node vulnerable/server.js",
"init-db": "node database/init.js"
},
"dependencies": {
"better-sqlite3": "^9.2.2"
}
}
Instala las dependencias:
npm install
Paso 3: Crear la base de datos
Crea el archivo database/init.js:
import Database from 'better-sqlite3';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const dbPath = join(__dirname, 'concesionario.db');
const db = new Database(dbPath);
// Crear tabla de vehículos
db.exec(`
CREATE TABLE IF NOT EXISTS vehiculos (
id INTEGER PRIMARY KEY AUTOINCREMENT,
marca TEXT NOT NULL,
modelo TEXT NOT NULL,
año INTEGER NOT NULL,
precio REAL NOT NULL,
color TEXT NOT NULL,
kilometraje INTEGER NOT NULL
);
`);
// Insertar datos de ejemplo
const insertVehiculo = db.prepare(`
INSERT INTO vehiculos (marca, modelo, año, precio, color, kilometraje)
VALUES (?, ?, ?, ?, ?, ?)
`);
const vehiculos = [
['Toyota', 'Corolla', 2022, 25000, 'Blanco', 15000],
['Honda', 'Civic', 2021, 23000, 'Negro', 22000],
['Ford', 'Mustang', 2023, 45000, 'Rojo', 5000],
['Chevrolet', 'Camaro', 2022, 42000, 'Amarillo', 8000],
['Tesla', 'Model 3', 2023, 55000, 'Azul', 3000],
['BMW', 'Serie 3', 2021, 48000, 'Gris', 18000],
['Mercedes', 'Clase C', 2022, 52000, 'Negro', 12000],
['Audi', 'A4', 2023, 50000, 'Blanco', 7000],
['Volkswagen', 'Golf', 2021, 22000, 'Rojo', 25000],
['Mazda', 'CX-5', 2022, 32000, 'Azul', 14000]
];
vehiculos.forEach(vehiculo => {
insertVehiculo.run(...vehiculo);
});
console.log('Base de datos inicializada correctamente con 10 vehículos');
console.log('Ubicación:', dbPath);
db.close();
Ejecuta el script para crear la base de datos:
npm run init-db
Deberías ver el mensaje: "Base de datos inicializada correctamente con 10 vehículos"
PARTE 2: SISTEMA VULNERABLE
Paso 4: Crear el servidor vulnerable
Crea el archivo vulnerable/server.js:
import http from 'http';
import { URL } from 'url';
import Database from 'better-sqlite3';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const dbPath = join(__dirname, '../database/concesionario.db');
const db = new Database(dbPath);
const PORT = 3000;
// FUNCIÓN VULNERABLE: Búsqueda por marca
function buscarPorMarca(marca) {
// VULNERABILIDAD: Concatenación directa del input del usuario
const query = `SELECT * FROM vehiculos WHERE marca = '${marca}'`;
console.log('Ejecutando query:', query);
try {
const vehiculos = db.prepare(query).all();
return vehiculos;
} catch (error) {
console.error('Error en la consulta:', error.message);
return [];
}
}
// FUNCIÓN VULNERABLE: Búsqueda por rango de precio
function buscarPorPrecio(precioMin, precioMax) {
// VULNERABILIDAD: Concatenación directa sin validación
const query = `SELECT * FROM vehiculos WHERE precio >= ${precioMin} AND precio <= ${precioMax}`;
console.log('Ejecutando query:', query);
try {
const vehiculos = db.prepare(query).all();
return vehiculos;
} catch (error) {
console.error('Error en la consulta:', error.message);
return [];
}
}
// FUNCIÓN VULNERABLE: Búsqueda combinada
function buscarCombinado(marca, añoMin) {
// VULNERABILIDAD: Múltiples parámetros sin sanitizar
const query = `SELECT * FROM vehiculos WHERE marca = '${marca}' AND año >= ${añoMin}`;
console.log('Ejecutando query:', query);
try {
const vehiculos = db.prepare(query).all();
return vehiculos;
} catch (error) {
console.error('Error en la consulta:', error.message);
return [];
}
}
const server = http.createServer((req, res) => {
const parsedUrl = new URL(req.url, `http://localhost:${PORT}`);
const pathname = parsedUrl.pathname;
// Configurar CORS y headers
res.setHeader('Content-Type', 'application/json');
res.setHeader('Access-Control-Allow-Origin', '*');
if (pathname === '/buscar/marca' && req.method === 'GET') {
const marca = parsedUrl.searchParams.get('marca');
if (!marca) {
res.writeHead(400);
res.end(JSON.stringify({ error: 'Parámetro marca requerido' }));
return;
}
const vehiculos = buscarPorMarca(marca);
res.writeHead(200);
res.end(JSON.stringify({ vehiculos, total: vehiculos.length }));
} else if (pathname === '/buscar/precio' && req.method === 'GET') {
const precioMin = parsedUrl.searchParams.get('min');
const precioMax = parsedUrl.searchParams.get('max');
if (!precioMin || !precioMax) {
res.writeHead(400);
res.end(JSON.stringify({ error: 'Parámetros min y max requeridos' }));
return;
}
const vehiculos = buscarPorPrecio(precioMin, precioMax);
res.writeHead(200);
res.end(JSON.stringify({ vehiculos, total: vehiculos.length }));
} else if (pathname === '/buscar/combinado' && req.method === 'GET') {
const marca = parsedUrl.searchParams.get('marca');
const añoMin = parsedUrl.searchParams.get('añoMin');
if (!marca || !añoMin) {
res.writeHead(400);
res.end(JSON.stringify({ error: 'Parámetros marca y añoMin requeridos' }));
return;
}
const vehiculos = buscarCombinado(marca, añoMin);
res.writeHead(200);
res.end(JSON.stringify({ vehiculos, total: vehiculos.length }));
} else {
res.writeHead(404);
res.end(JSON.stringify({ error: 'Ruta no encontrada' }));
}
});
server.listen(PORT, () => {
console.log(`Servidor VULNERABLE ejecutándose en http://localhost:${PORT}`);
console.log('Endpoints disponibles:');
console.log(` - GET /buscar/marca?marca=Toyota`);
console.log(` - GET /buscar/precio?min=20000&max=30000`);
console.log(` - GET /buscar/combinado?marca=Toyota&añoMin=2022`);
});
Inicia el servidor:
npm start
PARTE 3: PROBANDO EL SISTEMA VULNERABLE
Paso 5: Peticiones normales (funcionamiento esperado)
Abre otra terminal y prueba estas peticiones con curl:
Búsqueda normal por marca:
curl "http://localhost:3000/buscar/marca?marca=Toyota"
Resultado esperado: Solo vehículos Toyota
Búsqueda normal por precio:
curl "http://localhost:3000/buscar/precio?min=20000&max=30000"
Resultado esperado: Vehículos en ese rango de precio
Paso 6: ATAQUE 1 - Bypass de autenticación
Ahora vamos a explotar la vulnerabilidad. Prueba esta petición:
curl "http://localhost:3000/buscar/marca?marca=Toyota'%20OR%20'1'='1"
Explicación del ataque:
-
Enviamos:
Toyota' OR '1'='1 -
URL encoded:
Toyota'%20OR%20'1'='1 -
La consulta resultante es:
SELECT * FROM vehiculos WHERE marca = 'Toyota' OR '1'='1' -
Como '1'='1' es siempre verdadero, obtienes TODOS los vehículos
Observa la terminal del servidor y verás la consulta ejecutada.
Paso 7: ATAQUE 2 - Extracción de datos con UNION
Prueba este ataque más sofisticado:
curl "http://localhost:3000/buscar/marca?marca=Toyota'%20UNION%20SELECT%201,2,3,4,5,6%20--"
Explicación:
- Enviamos:
Toyota' UNION SELECT 1,2,3,4,5,6 -- - El
-comenta el resto de la consulta - UNION permite combinar resultados de múltiples SELECT
- Esto revela la estructura de la tabla (6 columnas)
Paso 8: ATAQUE 3 - Inyección en parámetros numéricos
Prueba este ataque en el endpoint de precio:
curl "http://localhost:3000/buscar/precio?min=0&max=999999%20OR%201=1"
Explicación:
-
Enviamos en max:
999999 OR 1=1 -
La consulta resultante:
SELECT * FROM vehiculos WHERE precio >= 0 AND precio <= 999999 OR 1=1 -
Devuelve todos los vehículos ignorando los filtros
Paso 9: ATAQUE 4 - Comentarios SQL
curl "http://localhost:3000/buscar/combinado?marca=Toyota'--&añoMin=2022"
Explicación:
-
Enviamos en marca:
Toyota'-- -
El
-comenta todo lo que viene después -
La consulta se convierte en:
SELECT * FROM vehiculos WHERE marca = 'Toyota'-- AND año >= 2022 -
Ignora completamente el filtro de año
PARTE 4: SISTEMA SEGURO
Paso 10: Preparar el proyecto seguro
Navega a la carpeta del proyecto seguro:
cd ../concesionario-seguro
Crea el archivo package.json:
{
"name": "concesionario-seguro",
"version": "1.0.0",
"type": "module",
"description": "Sistema seguro con consultas parametrizadas",
"main": "seguro/server.js",
"scripts": {
"start": "node seguro/server.js",
"init-db": "node database/init.js"
},
"dependencies": {
"better-sqlite3": "^9.2.2"
}
}
Instala las dependencias:
npm install
Paso 11: Copiar e inicializar la base de datos
Copia el archivo init.js de la carpeta vulnerable:
cp ../concesionario-vulnerable/database/init.js database/
Inicializa la base de datos:
npm run init-db
Paso 12: Crear el servidor seguro
Crea el archivo seguro/server.js:
import http from 'http';
import { URL } from 'url';
import Database from 'better-sqlite3';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const dbPath = join(__dirname, '../database/concesionario.db');
const db = new Database(dbPath);
const PORT = 3001;
// FUNCIÓN SEGURA: Búsqueda por marca con consulta parametrizada
function buscarPorMarca(marca) {
// SEGURIDAD: Uso de placeholders (?)
const query = 'SELECT * FROM vehiculos WHERE marca = ?';
console.log('Ejecutando query segura:', query);
console.log('Parámetro:', marca);
try {
// Los parámetros se pasan como segundo argumento, NUNCA en la query
const vehiculos = db.prepare(query).all(marca);
return vehiculos;
} catch (error) {
console.error('Error en la consulta:', error.message);
return [];
}
}
// FUNCIÓN SEGURA: Búsqueda por rango de precio
function buscarPorPrecio(precioMin, precioMax) {
// VALIDACIÓN: Convertir a números para asegurar el tipo de dato
const min = parseFloat(precioMin);
const max = parseFloat(precioMax);
// VALIDACIÓN: Verificar que son números válidos
if (isNaN(min) || isNaN(max)) {
throw new Error('Los precios deben ser números válidos');
}
// SEGURIDAD: Uso de placeholders para ambos parámetros
const query = 'SELECT * FROM vehiculos WHERE precio >= ? AND precio <= ?';
console.log('Ejecutando query segura:', query);
console.log('Parámetros:', min, max);
try {
const vehiculos = db.prepare(query).all(min, max);
return vehiculos;
} catch (error) {
console.error('Error en la consulta:', error.message);
return [];
}
}
// FUNCIÓN SEGURA: Búsqueda combinada
function buscarCombinado(marca, añoMin) {
// VALIDACIÓN: Convertir año a entero
const año = parseInt(añoMin);
// VALIDACIÓN: Verificar que es un número válido
if (isNaN(año)) {
throw new Error('El año debe ser un número válido');
}
// SEGURIDAD: Múltiples placeholders, uno por cada parámetro
const query = 'SELECT * FROM vehiculos WHERE marca = ? AND año >= ?';
console.log('Ejecutando query segura:', query);
console.log('Parámetros:', marca, año);
try {
const vehiculos = db.prepare(query).all(marca, año);
return vehiculos;
} catch (error) {
console.error('Error en la consulta:', error.message);
return [];
}
}
const server = http.createServer((req, res) => {
const parsedUrl = new URL(req.url, `http://localhost:${PORT}`);
const pathname = parsedUrl.pathname;
res.setHeader('Content-Type', 'application/json');
res.setHeader('Access-Control-Allow-Origin', '*');
if (pathname === '/buscar/marca' && req.method === 'GET') {
const marca = parsedUrl.searchParams.get('marca');
if (!marca) {
res.writeHead(400);
res.end(JSON.stringify({ error: 'Parámetro marca requerido' }));
return;
}
try {
const vehiculos = buscarPorMarca(marca);
res.writeHead(200);
res.end(JSON.stringify({ vehiculos, total: vehiculos.length }));
} catch (error) {
res.writeHead(500);
res.end(JSON.stringify({ error: error.message }));
}
} else if (pathname === '/buscar/precio' && req.method === 'GET') {
const precioMin = parsedUrl.searchParams.get('min');
const precioMax = parsedUrl.searchParams.get('max');
if (!precioMin || !precioMax) {
res.writeHead(400);
res.end(JSON.stringify({ error: 'Parámetros min y max requeridos' }));
return;
}
try {
const vehiculos = buscarPorPrecio(precioMin, precioMax);
res.writeHead(200);
res.end(JSON.stringify({ vehiculos, total: vehiculos.length }));
} catch (error) {
res.writeHead(400);
res.end(JSON.stringify({ error: error.message }));
}
} else if (pathname === '/buscar/combinado' && req.method === 'GET') {
const marca = parsedUrl.searchParams.get('marca');
const añoMin = parsedUrl.searchParams.get('añoMin');
if (!marca || !añoMin) {
res.writeHead(400);
res.end(JSON.stringify({ error: 'Parámetros marca y añoMin requeridos' }));
return;
}
try {
const vehiculos = buscarCombinado(marca, añoMin);
res.writeHead(200);
res.end(JSON.stringify({ vehiculos, total: vehiculos.length }));
} catch (error) {
res.writeHead(400);
res.end(JSON.stringify({ error: error.message }));
}
} else {
res.writeHead(404);
res.end(JSON.stringify({ error: 'Ruta no encontrada' }));
}
});
server.listen(PORT, () => {
console.log(`Servidor SEGURO ejecutándose en http://localhost:${PORT}`);
console.log('Endpoints disponibles:');
console.log(` - GET /buscar/marca?marca=Toyota`);
console.log(` - GET /buscar/precio?min=20000&max=30000`);
console.log(` - GET /buscar/combinado?marca=Toyota&añoMin=2022`);
});
Inicia el servidor seguro:
npm start
PARTE 5: PROBANDO EL SISTEMA SEGURO
Paso 13: Intentar los mismos ataques
Ahora intenta los mismos ataques contra el servidor seguro que corre en el puerto 3001:
Intento de bypass:
curl "http://localhost:3001/buscar/marca?marca=Toyota'%20OR%20'1'='1"
Resultado: Solo busca literalmente la marca "Toyota' OR '1'='1" que no existe. NO devuelve todos los vehículos.
Intento de UNION:
curl "http://localhost:3001/buscar/marca?marca=Toyota'%20UNION%20SELECT%201,2,3,4,5,6%20--"
Resultado: Busca literalmente esa cadena, no ejecuta el UNION.
Intento de inyección numérica:
curl "http://localhost:3001/buscar/precio?min=0&max=999999%20OR%201=1"
Resultado: Error porque "999999 OR 1=1" no es un número válido.
PARTE 6: COMPARACIÓN Y ANÁLISIS
Diferencias clave entre código vulnerable y seguro
VULNERABLE:
const query = `SELECT * FROM vehiculos WHERE marca = '${marca}'`;
const vehiculos = db.prepare(query).all();
Problemas:
- Concatenación directa del input del usuario
- El usuario controla parte de la consulta SQL
- No hay separación entre código y datos
- SQL interpreta el input del usuario como código
SEGURO:
const query = 'SELECT * FROM vehiculos WHERE marca = ?';
const vehiculos = db.prepare(query).all(marca);
Ventajas:
- Uso de placeholders (?)
- Los parámetros se pasan por separado
- La base de datos trata los parámetros como DATOS, nunca como código
- Imposible inyectar código SQL
Capas de seguridad en el código seguro
- Consultas parametrizadas: Uso de placeholders
- Validación de tipos: parseFloat, parseInt
- Validación de valores: isNaN checks
- Manejo de errores: try-catch blocks
- Mensajes de error seguros: No revelan estructura de BD
CONCLUSIONES DE LA PARTE 1
Has aprendido:
- Cómo se producen las inyecciones SQL por concatenación de strings
- Diferentes tipos de ataques en consultas SELECT: bypass, UNION, comentarios SQL
- Cómo explotar vulnerabilidades en un entorno controlado
- La solución correcta: consultas parametrizadas
- Validación adicional de tipos y valores
Continúa con la PARTE 2 para aprender sobre inyecciones en operaciones INSERT, UPDATE y DELETE, que son aún más peligrosas.
Ejercicio Práctico: Inyección SQL - PARTE 2: INSERT, UPDATE y DELETE
Esta es la continuación del ejercicio. Asegúrate de haber completado la PARTE 1 antes de continuar.
ANEXO A: INYECCIONES SQL EN OPERACIONES INSERT
Contexto
Las operaciones INSERT son especialmente peligrosas porque permiten a un atacante insertar datos maliciosos en la base de datos, modificar múltiples registros o incluso ejecutar comandos destructivos.
ANEXO A.1: Preparación del entorno para INSERT
Paso A1: Añadir endpoint vulnerable para registrar vehículos
En el archivo vulnerable/server.js, añade esta función ANTES de la creación del servidor:
// FUNCIÓN VULNERABLE: Registrar nuevo vehículo
function registrarVehiculo(marca, modelo, año, precio, color, kilometraje) {
// VULNERABILIDAD: Concatenación directa en INSERT
const query = `INSERT INTO vehiculos (marca, modelo, año, precio, color, kilometraje)
VALUES ('${marca}', '${modelo}', ${año}, ${precio}, '${color}', ${kilometraje})`;
console.log('Ejecutando query INSERT:', query);
try {
const result = db.prepare(query).run();
return { id: result.lastInsertRowid, success: true };
} catch (error) {
console.error('Error en INSERT:', error.message);
return { success: false, error: error.message };
}
}
Ahora añade este nuevo endpoint DENTRO del http.createServer, después del último else if:
} else if (pathname === '/registrar' && req.method === 'POST') {
let body = '';
req.on('data', chunk => {
body += chunk.toString();
});
req.on('end', () => {
try {
const datos = JSON.parse(body);
const { marca, modelo, año, precio, color, kilometraje } = datos;
if (!marca || !modelo || !año || !precio || !color || !kilometraje) {
res.writeHead(400);
res.end(JSON.stringify({ error: 'Todos los campos son requeridos' }));
return;
}
const resultado = registrarVehiculo(marca, modelo, año, precio, color, kilometraje);
if (resultado.success) {
res.writeHead(201);
res.end(JSON.stringify({ mensaje: 'Vehículo registrado', id: resultado.id }));
} else {
res.writeHead(500);
res.end(JSON.stringify({ error: resultado.error }));
}
} catch (error) {
res.writeHead(400);
res.end(JSON.stringify({ error: 'JSON inválido' }));
}
});
Actualiza también el mensaje de inicio del servidor para incluir el nuevo endpoint:
console.log(` - POST /registrar (JSON: marca, modelo, año, precio, color, kilometraje)`);
Reinicia el servidor vulnerable.
ANEXO A.2: Petición INSERT normal
Prueba primero una inserción legítima:
curl -X POST http://localhost:3000/registrar \
-H "Content-Type: application/json" \
-d '{
"marca": "Nissan",
"modelo": "Sentra",
"año": 2023,
"precio": 28000,
"color": "Plata",
"kilometraje": 5000
}'
Verifica que se insertó correctamente:
curl "http://localhost:3000/buscar/marca?marca=Nissan"
ANEXO A.3: ATAQUE INSERT 1 - Inserción múltiple
Un atacante puede insertar múltiples registros en una sola petición:
curl -X POST http://localhost:3000/registrar \
-H "Content-Type: application/json" \
-d '{
"marca": "Hyundai",
"modelo": "Elantra'\''),(''Falso'',''Malicioso'',1999,1,''Negro'',999999);--",
"año": 2023,
"precio": 27000,
"color": "Blanco",
"kilometraje": 8000
}'
Explicación del ataque:
Input malicioso en modelo: Elantra'),('Falso','Malicioso',1999,1,'Negro',999999);--
La consulta resultante:
INSERT INTO vehiculos (marca, modelo, año, precio, color, kilometraje)
VALUES ('Hyundai', 'Elantra'),('Falso','Malicioso',1999,1,'Negro',999999);--', 2023, 27000, 'Blanco', 8000)
Esto inserta DOS vehículos:
- El Hyundai Elantra legítimo
- Un vehículo falso adicional
El -- comenta el resto de la consulta original.
Verifica el ataque:
curl "http://localhost:3000/buscar/marca?marca=Falso"
ANEXO A.4: ATAQUE INSERT 2 - Inyección con punto y coma
Un ataque aún más peligroso es ejecutar múltiples sentencias SQL:
curl -X POST http://localhost:3000/registrar \
-H "Content-Type: application/json" \
-d '{
"marca": "Kia",
"modelo": "Rio'\''); UPDATE vehiculos SET precio = 1 WHERE marca = '\''Toyota'\''; --",
"año": 2023,
"precio": 26000,
"color": "Verde",
"kilometraje": 7000
}'
Explicación del ataque:
Input malicioso: Rio'); UPDATE vehiculos SET precio = 1 WHERE marca = 'Toyota'; --
La consulta resultante:
INSERT INTO vehiculos (marca, modelo, año, precio, color, kilometraje)
VALUES ('Kia', 'Rio'); UPDATE vehiculos SET precio = 1 WHERE marca = 'Toyota'; --', 2023, 26000, 'Verde', 7000)
Esto ejecuta:
- El INSERT del Kia Rio
- Un UPDATE que cambia el precio de todos los Toyota a 1 euro
Verifica el daño:
curl "http://localhost:3000/buscar/marca?marca=Toyota"
Todos los Toyota ahora cuestan 1 euro.
ANEXO A.5: Solución segura para INSERT
En el archivo seguro/server.js, añade esta función SEGURA:
// FUNCIÓN SEGURA: Registrar nuevo vehículo
function registrarVehiculo(marca, modelo, año, precio, color, kilometraje) {
// VALIDACIÓN: Convertir y validar tipos
const añoNum = parseInt(año);
const precioNum = parseFloat(precio);
const kmNum = parseInt(kilometraje);
if (isNaN(añoNum) || isNaN(precioNum) || isNaN(kmNum)) {
throw new Error('Año, precio y kilometraje deben ser números válidos');
}
// VALIDACIÓN: Verificar rangos razonables
if (añoNum < 1900 || añoNum > 2030) {
throw new Error('Año fuera de rango válido');
}
if (precioNum < 0 || precioNum > 10000000) {
throw new Error('Precio fuera de rango válido');
}
if (kmNum < 0) {
throw new Error('Kilometraje no puede ser negativo');
}
// VALIDACIÓN: Verificar longitud de strings
if (marca.length > 50 || modelo.length > 50 || color.length > 30) {
throw new Error('Longitud de texto excede el límite permitido');
}
// SEGURIDAD: Consulta parametrizada con placeholders
const query = `INSERT INTO vehiculos (marca, modelo, año, precio, color, kilometraje)
VALUES (?, ?, ?, ?, ?, ?)`;
console.log('Ejecutando query INSERT segura:', query);
console.log('Parámetros:', marca, modelo, añoNum, precioNum, color, kmNum);
try {
const result = db.prepare(query).run(marca, modelo, añoNum, precioNum, color, kmNum);
return { id: result.lastInsertRowid, success: true };
} catch (error) {
console.error('Error en INSERT:', error.message);
throw error;
}
}
Añade el endpoint seguro (mismo código que en vulnerable pero con manejo de errores mejorado):
} else if (pathname === '/registrar' && req.method === 'POST') {
let body = '';
req.on('data', chunk => {
body += chunk.toString();
});
req.on('end', () => {
try {
const datos = JSON.parse(body);
const { marca, modelo, año, precio, color, kilometraje } = datos;
if (!marca || !modelo || !año || !precio || !color || !kilometraje) {
res.writeHead(400);
res.end(JSON.stringify({ error: 'Todos los campos son requeridos' }));
return;
}
const resultado = registrarVehiculo(marca, modelo, año, precio, color, kilometraje);
res.writeHead(201);
res.end(JSON.stringify({ mensaje: 'Vehículo registrado', id: resultado.id }));
} catch (error) {
res.writeHead(400);
res.end(JSON.stringify({ error: error.message }));
}
});
ANEXO B: INYECCIONES SQL EN OPERACIONES UPDATE
Contexto
Las operaciones UPDATE son extremadamente peligrosas porque permiten a un atacante modificar datos existentes, afectando potencialmente toda la base de datos.
ANEXO B.1: Añadir endpoint vulnerable para actualizar precio
En vulnerable/server.js, añade esta función:
// FUNCIÓN VULNERABLE: Actualizar precio de vehículo
function actualizarPrecio(id, nuevoPrecio) {
// VULNERABILIDAD: Concatenación directa en UPDATE
const query = `UPDATE vehiculos SET precio = ${nuevoPrecio} WHERE id = ${id}`;
console.log('Ejecutando query UPDATE:', query);
try {
const result = db.prepare(query).run();
return { modificados: result.changes, success: true };
} catch (error) {
console.error('Error en UPDATE:', error.message);
return { success: false, error: error.message };
}
}
Añade el endpoint en el servidor:
} else if (pathname === '/actualizar-precio' && req.method === 'PUT') {
let body = '';
req.on('data', chunk => {
body += chunk.toString();
});
req.on('end', () => {
try {
const datos = JSON.parse(body);
const { id, precio } = datos;
if (!id || !precio) {
res.writeHead(400);
res.end(JSON.stringify({ error: 'Parámetros id y precio requeridos' }));
return;
}
const resultado = actualizarPrecio(id, precio);
if (resultado.success) {
res.writeHead(200);
res.end(JSON.stringify({
mensaje: 'Precio actualizado',
modificados: resultado.modificados
}));
} else {
res.writeHead(500);
res.end(JSON.stringify({ error: resultado.error }));
}
} catch (error) {
res.writeHead(400);
res.end(JSON.stringify({ error: 'JSON inválido' }));
}
});
ANEXO B.2: ATAQUE UPDATE 1 - Modificación masiva con WHERE
Un atacante puede modificar múltiples registros usando OR:
curl -X PUT http://localhost:3000/actualizar-precio \
-H "Content-Type: application/json" \
-d '{
"id": "1 OR 1=1",
"precio": 100
}'
Explicación del ataque:
Input malicioso en id: 1 OR 1=1
La consulta resultante:
UPDATE vehiculos SET precio = 100 WHERE id = 1 OR 1=1
Como 1=1 es siempre verdadero, actualiza TODOS los vehículos a 100 euros.
Verifica el desastre:
curl "http://localhost:3000/buscar/precio?min=0&max=200"
Todos los vehículos ahora cuestan 100 euros.
ANEXO B.3: ATAQUE UPDATE 2 - Modificación de múltiples columnas
curl -X PUT http://localhost:3000/actualizar-precio \
-H "Content-Type: application/json" \
-d '{
"id": 2,
"precio": "50000, kilometraje = 0, marca = '\''HACKEADO'\'' WHERE id = 2; UPDATE vehiculos SET precio = 1 WHERE 1=1; --"
}'
Explicación del ataque:
Input malicioso: 50000, kilometraje = 0, marca = 'HACKEADO' WHERE id = 2; UPDATE vehiculos SET precio = 1 WHERE 1=1; --
La consulta resultante ejecuta múltiples modificaciones y UPDATE adicionales.
ANEXO B.4: Solución segura para UPDATE
En seguro/server.js, añade esta función SEGURA:
// FUNCIÓN SEGURA: Actualizar precio de vehículo
function actualizarPrecio(id, nuevoPrecio) {
// VALIDACIÓN: Convertir a números
const idNum = parseInt(id);
const precioNum = parseFloat(nuevoPrecio);
if (isNaN(idNum) || isNaN(precioNum)) {
throw new Error('ID y precio deben ser números válidos');
}
// VALIDACIÓN: Verificar que el ID es positivo
if (idNum <= 0) {
throw new Error('ID debe ser un número positivo');
}
// VALIDACIÓN: Verificar rango de precio
if (precioNum < 0 || precioNum > 10000000) {
throw new Error('Precio fuera de rango válido');
}
// SEGURIDAD: Consulta parametrizada
const query = 'UPDATE vehiculos SET precio = ? WHERE id = ?';
console.log('Ejecutando query UPDATE segura:', query);
console.log('Parámetros:', precioNum, idNum);
try {
const result = db.prepare(query).run(precioNum, idNum);
return { modificados: result.changes, success: true };
} catch (error) {
console.error('Error en UPDATE:', error.message);
throw error;
}
}
ANEXO C: INYECCIONES SQL EN OPERACIONES DELETE
Contexto
Las operaciones DELETE son las MÁS PELIGROSAS porque pueden eliminar datos permanentemente, incluyendo TODA la base de datos.
ANEXO C.1: Añadir endpoint vulnerable para eliminar vehículos
En vulnerable/server.js, añade esta función:
// FUNCIÓN VULNERABLE: Eliminar vehículo
function eliminarVehiculo(id) {
// VULNERABILIDAD: Concatenación directa en DELETE
const query = `DELETE FROM vehiculos WHERE id = ${id}`;
console.log('Ejecutando query DELETE:', query);
try {
const result = db.prepare(query).run();
return { eliminados: result.changes, success: true };
} catch (error) {
console.error('Error en DELETE:', error.message);
return { success: false, error: error.message };
}
}
Añade el endpoint:
} else if (pathname === '/eliminar' && req.method === 'DELETE') {
const id = parsedUrl.searchParams.get('id');
if (!id) {
res.writeHead(400);
res.end(JSON.stringify({ error: 'Parámetro id requerido' }));
return;
}
const resultado = eliminarVehiculo(id);
if (resultado.success) {
res.writeHead(200);
res.end(JSON.stringify({
mensaje: 'Vehículo eliminado',
eliminados: resultado.eliminados
}));
} else {
res.writeHead(500);
res.end(JSON.stringify({ error: resultado.error }));
}
ANEXO C.2: ATAQUE DELETE 1 - Eliminación masiva
Este es el ataque MÁS DESTRUCTIVO:
curl -X DELETE "http://localhost:3000/eliminar?id=1%20OR%201=1"
Explicación del ataque:
Input malicioso: 1 OR 1=1 (URL encoded: 1%20OR%201=1)
La consulta resultante:
DELETE FROM vehiculos WHERE id = 1 OR 1=1
Como 1=1 es siempre verdadero, elimina TODOS los vehículos de la base de datos.
Verifica el desastre:
curl "http://localhost:3000/buscar/precio?min=0&max=999999"
La tabla está vacía. Has perdido todos los datos.
IMPORTANTE: Antes de continuar, reinicializa la base de datos:
cd concesionario-vulnerable
npm run init-db
npm start
ANEXO C.3: ATAQUE DELETE 2 - DROP TABLE (el más catastrófico)
ADVERTENCIA: Este ataque ELIMINARÁ la tabla completa.
curl -X DELETE "http://localhost:3000/eliminar?id=1;%20DROP%20TABLE%20vehiculos;%20--"
Explicación del ataque:
Input malicioso: 1; DROP TABLE vehiculos; --
La consulta resultante:
DELETE FROM vehiculos WHERE id = 1; DROP TABLE vehiculos; --
Esto ejecuta:
- Elimina el vehículo con id=1
- ELIMINA COMPLETAMENTE LA TABLA vehiculos
Después de este ataque, cualquier consulta fallará porque la tabla ya no existe.
ANEXO C.4: Solución segura para DELETE
En seguro/server.js, añade esta función SEGURA:
// FUNCIÓN SEGURA: Eliminar vehículo
function eliminarVehiculo(id) {
// VALIDACIÓN: Convertir a número
const idNum = parseInt(id);
if (isNaN(idNum)) {
throw new Error('ID debe ser un número válido');
}
// VALIDACIÓN: Verificar que el ID es positivo
if (idNum <= 0) {
throw new Error('ID debe ser un número positivo');
}
// SEGURIDAD ADICIONAL: Verificar que el vehículo existe antes de eliminar
const existe = db.prepare('SELECT id FROM vehiculos WHERE id = ?').get(idNum);
if (!existe) {
throw new Error('El vehículo con ese ID no existe');
}
// SEGURIDAD: Consulta parametrizada
const query = 'DELETE FROM vehiculos WHERE id = ?';
console.log('Ejecutando query DELETE segura:', query);
console.log('Parámetro:', idNum);
try {
const result = db.prepare(query).run(idNum);
return { eliminados: result.changes, success: true };
} catch (error) {
console.error('Error en DELETE:', error.message);
throw error;
}
}
ANEXO D: RESUMEN Y BUENAS PRÁCTICAS
Tabla comparativa de ataques
| Operación | Ataque | Impacto | Protección |
|---|---|---|---|
| INSERT | Inserción múltiple | Datos falsos en BD | Consultas parametrizadas |
| INSERT | Ejecución de UPDATE | Modificación masiva | Validación de tipos |
| UPDATE | WHERE con OR | Modificación masiva | Placeholders + validación |
| UPDATE | Múltiples columnas | Control total del registro | Queries específicas |
| DELETE | OR 1=1 | Pérdida total de datos | Validación estricta de ID |
| DELETE | DROP TABLE | Destrucción de estructura | Separación de privilegios |
Capas de defensa implementadas
1. Consultas parametrizadas
// VULNERABLE
const query = `DELETE FROM vehiculos WHERE id = ${id}`;
// SEGURO
const query = 'DELETE FROM vehiculos WHERE id = ?';
db.prepare(query).run(idNum);
2. Validación de tipos de datos
const idNum = parseInt(id);
if (isNaN(idNum)) {
throw new Error('ID debe ser un número válido');
}
3. Validación de rangos
if (precioNum < 0 || precioNum > 10000000) {
throw new Error('Precio fuera de rango válido');
}
4. Verificación de existencia
const existe = db.prepare('SELECT id FROM vehiculos WHERE id = ?').get(idNum);
if (!existe) {
throw new Error('El vehículo no existe');
}
Análisis de impacto real
Escenario 1: E-commerce
Un atacante podría:
- Insertar productos con precio 0
- Modificar precios de productos existentes
- Eliminar todo el inventario
Consecuencias: Pérdida económica directa, desconfianza del cliente
Escenario 2: Sistema bancario
Un atacante podría:
- Modificar saldos de cuentas
- Cambiar límites de crédito
- Alterar transacciones
Consecuencias: Pérdida monetaria, violación de regulaciones, demandas
Escenario 3: Sistema médico
Un atacante podría:
- Eliminar historiales médicos
- Modificar medicación
- Destruir información de pacientes
Consecuencias: Riesgo para vidas, violación de GDPR, pérdida permanente de datos
Buenas prácticas adicionales
1. Principio de mínimo privilegio
Crea usuarios de BD con permisos limitados:
- Usuario de lectura: solo SELECT
- Usuario de escritura: INSERT, UPDATE, SELECT
- Usuario admin: solo para mantenimiento
2. Uso de transacciones
const transaction = db.transaction(() => {
// Múltiples operaciones que deben ejecutarse juntas
db.prepare('UPDATE ...').run(params);
db.prepare('INSERT ...').run(params);
});
transaction(); // Todo o nada
3. Rate limiting
const intentos = new Map();
function verificarRateLimit(ip) {
const registro = intentos.get(ip) || { count: 0, inicio: Date.now() };
if (Date.now() - registro.inicio > 60000) {
registro.count = 0;
registro.inicio = Date.now();
}
registro.count++;
intentos.set(ip, registro);
if (registro.count > 10) {
throw new Error('Demasiadas peticiones');
}
}
4. Logging de actividad sospechosa
function detectarPatronPeligroso(query, params) {
const patronesPeligrosos = [
/union\s+select/i,
/drop\s+table/i,
/;\s*delete/i,
/'\s+or\s+'1'\s*=\s*'1/i
];
const sospechoso = patronesPeligrosos.some(patron =>
patron.test(query) || params.some(p => patron.test(String(p)))
);
if (sospechoso) {
console.error('ALERTA DE SEGURIDAD:', { query, params, timestamp: new Date() });
}
}
Checklist de seguridad final
Antes de desplegar en producción:
- Todas las consultas usan placeholders
- Todos los parámetros numéricos se validan
- Se verifican rangos válidos
- Los strings tienen límites de longitud
- Se usan transacciones para operaciones relacionadas
- Hay logging de actividad sospechosa
- Los errores NO revelan estructura de BD
- Se implementa rate limiting
- Los usuarios de BD tienen permisos mínimos
- Hay backups automáticos
- Existe un plan de respuesta ante incidentes
Herramienta de testing automatizado
Crea test-seguridad.js:
import http from 'http';
const ataques = [
{ nombre: 'OR 1=1', payload: "Toyota' OR '1'='1" },
{ nombre: 'UNION SELECT', payload: "Toyota' UNION SELECT 1,2,3,4,5,6 --" },
{ nombre: 'DROP TABLE', payload: "1; DROP TABLE vehiculos; --" },
{ nombre: 'Comentarios', payload: "Toyota'--" }
];
function probarAtaque(puerto, ataque) {
return new Promise((resolve) => {
const url = `http://localhost:${puerto}/buscar/marca?marca=${encodeURIComponent(ataque.payload)}`;
http.get(url, (res) => {
let data = '';
res.on('data', chunk => data += chunk);
res.on('end', () => {
const resultado = JSON.parse(data);
resolve({
ataque: ataque.nombre,
vulnerable: resultado.total > 5
});
});
});
});
}
async function ejecutarPruebas() {
console.log('Probando servidor VULNERABLE (puerto 3000)...');
for (const ataque of ataques) {
const resultado = await probarAtaque(3000, ataque);
console.log(` ${resultado.ataque}: ${resultado.vulnerable ? 'VULNERABLE' : 'Protegido'}`);
}
console.log('\nProbando servidor SEGURO (puerto 3001)...');
for (const ataque of ataques) {
const resultado = await probarAtaque(3001, ataque);
console.log(` ${resultado.ataque}: ${resultado.vulnerable ? 'VULNERABLE' : 'Protegido'}`);
}
}
ejecutarPruebas();
CONCLUSIÓN FINAL
Has aprendido sobre los tres tipos más peligrosos de inyección SQL:
- INSERT: Puede insertar datos maliciosos y ejecutar comandos adicionales
- UPDATE: Puede modificar registros masivamente destruyendo integridad
- DELETE: Puede eliminar datos permanentemente, incluyendo tablas completas
Regla de oro universal:
NUNCA concatenes input del usuario en consultas SQL. SIEMPRE usa consultas parametrizadas con placeholders.
La seguridad no es opcional. Un solo endpoint vulnerable puede comprometer toda tu aplicación y los datos de tus usuarios.
Recursos recomendados:
- OWASP SQL Injection Prevention Cheat Sheet
- SQLite Security Best Practices
- Node.js Security Checklist
- Better-sqlite3 Documentation