Lo que vas a construir
Una API REST segura con Express.js que implementa las mejores practicas de seguridad para aplicaciones web.
Empezaras con una API vulnerable y paso a paso la convertiras en una API hardened con:
- Headers de seguridad con Helmet.js
- Rate limiting para prevenir ataques de fuerza bruta
- Validacion de entrada con Zod
- Prevencion de SQL Injection con queries parametrizadas
- Proteccion XSS
- Configuracion segura de CORS
- Auditoria de dependencias en CI/CD
Al terminar tendras una API lista para produccion con un checklist de seguridad verificable.
Paso 1: Crea el proyecto base
mkdir secure-api && cd secure-api
npm init -y
npm install express
Crea server.js con una API basica (vulnerable):
// server.js - VERSION VULNERABLE (NO USAR EN PRODUCCION)
const express = require('express');
const app = express();
app.use(express.json());
// Base de datos simulada
const users = [
{ id: 1, name: 'Admin', email: 'admin@example.com', password: 'admin123' }
];
// Endpoint vulnerable a varios ataques
app.get('/api/users', (req, res) => {
res.json(users); // Expone passwords!
});
app.post('/api/login', (req, res) => {
const { email, password } = req.body;
// Sin validacion de entrada
// Vulnerable a inyeccion
const user = users.find(u => u.email === email && u.password === password);
if (user) {
res.json({ message: 'Login exitoso', user }); // Expone todo el objeto
} else {
res.status(401).json({ message: 'Credenciales invalidas' });
}
});
app.listen(3000, () => console.log('Server en http://localhost:3000'));
Paso 2: Agrega Helmet.js para headers de seguridad
Helmet configura automaticamente headers HTTP que protegen contra vulnerabilidades comunes.
npm install helmet
Antes (sin headers de seguridad):
const app = express();
// Sin proteccion - headers por defecto de Express
Despues (con Helmet):
const express = require('express');
const helmet = require('helmet');
const app = express();
// Helmet agrega 11+ headers de seguridad automaticamente
app.use(helmet());
// Headers que Helmet configura:
// - Content-Security-Policy: previene XSS
// - X-Content-Type-Options: nosniff
// - X-Frame-Options: DENY (previene clickjacking)
// - Strict-Transport-Security: fuerza HTTPS
// - X-XSS-Protection: filtro XSS del navegador
Verifica los headers:
curl -I http://localhost:3000/api/users
Paso 3: Implementa Rate Limiting
Previene ataques de fuerza bruta y DDoS limitando las peticiones por IP.
npm install express-rate-limit
Antes (sin limites):
// Un atacante puede hacer millones de peticiones
app.post('/api/login', (req, res) => { ... });
Despues (con rate limiting):
const rateLimit = require('express-rate-limit');
// Limite global: 100 peticiones por 15 minutos
const globalLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutos
max: 100,
message: { error: 'Demasiadas peticiones. Intenta en 15 minutos.' },
standardHeaders: true,
legacyHeaders: false,
});
// Limite estricto para login: 5 intentos por 15 minutos
const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 5,
message: { error: 'Demasiados intentos de login. Cuenta bloqueada temporalmente.' },
skipSuccessfulRequests: true, // No cuenta logins exitosos
});
app.use(globalLimiter);
app.post('/api/login', loginLimiter, (req, res) => { ... });
Paso 4: Validacion de entrada con Zod
Nunca confies en datos del usuario. Valida TODO.
npm install zod
Antes (sin validacion):
app.post('/api/login', (req, res) => {
const { email, password } = req.body;
// Si email es un objeto o array, puede causar errores
// Si password es muy largo, puede causar DoS
});
Despues (con Zod):
const { z } = require('zod');
// Definir esquema de validacion
const loginSchema = z.object({
email: z.string()
.email('Email invalido')
.max(255, 'Email muy largo'),
password: z.string()
.min(8, 'Password debe tener al menos 8 caracteres')
.max(128, 'Password muy largo'),
});
// Middleware de validacion reutilizable
const validate = (schema) => (req, res, next) => {
try {
schema.parse(req.body);
next();
} catch (error) {
res.status(400).json({
error: 'Datos invalidos',
details: error.errors.map(e => ({
field: e.path.join('.'),
message: e.message
}))
});
}
};
app.post('/api/login', validate(loginSchema), (req, res) => {
const { email, password } = req.body;
// Ahora sabemos que email y password son strings validos
});
// Esquema para crear usuario
const userSchema = z.object({
name: z.string().min(2).max(100).regex(/^[a-zA-Z\s]+$/, 'Solo letras'),
email: z.string().email().max(255),
password: z.string()
.min(8)
.regex(/[A-Z]/, 'Debe tener mayuscula')
.regex(/[0-9]/, 'Debe tener numero')
.regex(/[^A-Za-z0-9]/, 'Debe tener caracter especial'),
});
Paso 5: Prevencion de SQL Injection
Si usas base de datos SQL, SIEMPRE usa queries parametrizadas.
Antes (VULNERABLE):
// NUNCA hagas esto - vulnerable a SQL injection
app.get('/api/users/:id', async (req, res) => {
const query = \`SELECT * FROM users WHERE id = \${req.params.id}\`;
// Atacante puede enviar: 1; DROP TABLE users; --
const result = await db.query(query);
res.json(result);
});
Despues (SEGURO) - con pg (PostgreSQL):
const { Pool } = require('pg');
const pool = new Pool();
app.get('/api/users/:id', async (req, res) => {
try {
// Query parametrizada - el driver escapa automaticamente
const result = await pool.query(
'SELECT id, name, email FROM users WHERE id = $1',
[req.params.id] // Parametro separado
);
if (result.rows.length === 0) {
return res.status(404).json({ error: 'Usuario no encontrado' });
}
res.json(result.rows[0]);
} catch (error) {
console.error('DB Error:', error);
res.status(500).json({ error: 'Error interno' });
}
});
// Para INSERT tambien usa parametros
app.post('/api/users', validate(userSchema), async (req, res) => {
const { name, email, password } = req.body;
const hashedPassword = await bcrypt.hash(password, 12);
const result = await pool.query(
'INSERT INTO users (name, email, password) VALUES ($1, $2, $3) RETURNING id, name, email',
[name, email, hashedPassword]
);
res.status(201).json(result.rows[0]);
});
Con un ORM (Prisma) - tambien seguro:
// Prisma usa queries parametrizadas internamente
const user = await prisma.user.findUnique({
where: { id: parseInt(req.params.id) },
select: { id: true, name: true, email: true } // No seleccionar password
});
Paso 6: Proteccion XSS
Cross-Site Scripting ocurre cuando un atacante inyecta scripts maliciosos.
Antes (VULNERABLE):
app.get('/api/comments', async (req, res) => {
const comments = await db.getComments();
res.json(comments);
// Si un comentario contiene <script>alert('hacked')</script>
// y el frontend lo renderiza con innerHTML, se ejecuta
});
Despues (SEGURO):
const createDOMPurify = require('dompurify');
const { JSDOM } = require('jsdom');
const window = new JSDOM('').window;
const DOMPurify = createDOMPurify(window);
// Sanitizar al guardar
app.post('/api/comments', validate(commentSchema), async (req, res) => {
const sanitizedContent = DOMPurify.sanitize(req.body.content, {
ALLOWED_TAGS: ['b', 'i', 'em', 'strong'], // Solo tags seguros
ALLOWED_ATTR: [] // Sin atributos
});
await db.createComment({
content: sanitizedContent,
userId: req.user.id
});
res.status(201).json({ message: 'Comentario creado' });
});
// Alternativa: escapar HTML en el frontend
// React lo hace automaticamente con JSX
// Vue lo hace con {{ }} (doble llave)
Headers adicionales (ya incluidos en Helmet):
// Content-Security-Policy estricto
app.use(helmet.contentSecurityPolicy({
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'"], // Solo scripts del mismo origen
styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", "data:", "https:"],
connectSrc: ["'self'"],
fontSrc: ["'self'"],
objectSrc: ["'none'"],
upgradeInsecureRequests: [],
},
}));
Paso 7: Configuracion segura de CORS
CORS controla que dominios pueden acceder a tu API.
npm install cors
Antes (INSEGURO):
const cors = require('cors');
app.use(cors()); // Permite TODOS los origenes - peligroso!
Despues (SEGURO):
const cors = require('cors');
const allowedOrigins = [
'https://miapp.com',
'https://www.miapp.com',
process.env.NODE_ENV === 'development' && 'http://localhost:3000'
].filter(Boolean);
app.use(cors({
origin: (origin, callback) => {
// Permitir requests sin origin (mobile apps, Postman)
if (!origin) return callback(null, true);
if (allowedOrigins.includes(origin)) {
callback(null, true);
} else {
callback(new Error('CORS no permitido'));
}
},
methods: ['GET', 'POST', 'PUT', 'DELETE'],
allowedHeaders: ['Content-Type', 'Authorization'],
credentials: true, // Permite cookies
maxAge: 86400 // Cache preflight por 24 horas
}));
Paso 8: npm audit en CI/CD
Detecta vulnerabilidades en dependencias automaticamente.
Crea .github/workflows/security.yml:
name: Security Audit
on:
push:
branches: [main]
pull_request:
branches: [main]
schedule:
- cron: '0 0 * * 1' # Cada lunes a medianoche
jobs:
audit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install dependencies
run: npm ci
- name: Run security audit
run: npm audit --audit-level=high
# Falla si hay vulnerabilidades high o critical
- name: Run Snyk security scan
uses: snyk/actions/node@master
env:
SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
with:
args: --severity-threshold=high
continue-on-error: true # No bloquear, solo alertar
dependency-review:
runs-on: ubuntu-latest
if: github.event_name == 'pull_request'
steps:
- uses: actions/checkout@v4
- name: Dependency Review
uses: actions/dependency-review-action@v3
with:
fail-on-severity: high
Comandos locales utiles:
# Ver vulnerabilidades
npm audit
# Arreglar automaticamente
npm audit fix
# Arreglar incluyendo breaking changes
npm audit fix --force
# Ver solo vulnerabilidades criticas
npm audit --audit-level=critical
Paso 9: Codigo completo seguro
// server.js - VERSION SEGURA
require('dotenv').config();
const express = require('express');
const helmet = require('helmet');
const cors = require('cors');
const rateLimit = require('express-rate-limit');
const { z } = require('zod');
const bcrypt = require('bcrypt');
const app = express();
// 1. Security headers
app.use(helmet());
// 2. CORS configurado
const allowedOrigins = process.env.ALLOWED_ORIGINS?.split(',') || [];
app.use(cors({
origin: allowedOrigins,
credentials: true
}));
// 3. Rate limiting
app.use(rateLimit({
windowMs: 15 * 60 * 1000,
max: 100
}));
app.use(express.json({ limit: '10kb' })); // Limitar tamano del body
// 4. Esquemas de validacion
const loginSchema = z.object({
email: z.string().email().max(255),
password: z.string().min(8).max(128)
});
const validate = (schema) => (req, res, next) => {
const result = schema.safeParse(req.body);
if (!result.success) {
return res.status(400).json({ error: 'Datos invalidos' });
}
next();
};
// 5. Login seguro
const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 5,
skipSuccessfulRequests: true
});
app.post('/api/login', loginLimiter, validate(loginSchema), async (req, res) => {
try {
const { email, password } = req.body;
// Buscar usuario (query parametrizada)
const user = await db.query(
'SELECT id, email, password_hash FROM users WHERE email = $1',
[email]
);
if (!user.rows[0]) {
// Tiempo constante para prevenir timing attacks
await bcrypt.compare(password, '$2b$12$invalidhashtopreventtiming');
return res.status(401).json({ error: 'Credenciales invalidas' });
}
const valid = await bcrypt.compare(password, user.rows[0].password_hash);
if (!valid) {
return res.status(401).json({ error: 'Credenciales invalidas' });
}
// Respuesta sin datos sensibles
res.json({
user: { id: user.rows[0].id, email: user.rows[0].email }
});
} catch (error) {
console.error('Login error:', error);
res.status(500).json({ error: 'Error interno' });
}
});
// 6. Manejo de errores global
app.use((err, req, res, next) => {
console.error(err.stack);
res.status(500).json({ error: 'Algo salio mal' });
});
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(\`Servidor seguro en puerto \${PORT}\`);
});
Paso 10: Checklist de verificacion
Antes de ir a produccion, verifica cada punto:
| Categoria | Item | Comando/Verificacion |
|---|---|---|
| Headers | Helmet activo | curl -I localhost:3000 |
| Headers | CSP configurado | Verificar Content-Security-Policy |
| Rate Limit | Global activo | Hacer 101+ requests |
| Rate Limit | Login estricto | Hacer 6+ logins fallidos |
| Validacion | Zod en endpoints | Enviar datos invalidos |
| SQL | Queries parametrizadas | Revisar codigo |
| XSS | Sanitizacion activa | Enviar <script>alert(1)</script> |
| CORS | Solo origenes permitidos | Request desde otro dominio |
| Deps | Sin vulnerabilidades | npm audit |
| CI/CD | Audit automatico | Revisar GitHub Actions |
| Passwords | Hasheados con bcrypt | Revisar DB |
| Errores | Sin stack traces | Forzar error en prod |
| HTTPS | Forzado en prod | Verificar redirect |
Troubleshooting
| Problema | Causa | Solucion |
|---|---|---|
| CORS bloqueado | Origen no en lista | Agregar a allowedOrigins |
| Rate limit muy estricto | Pocos requests permitidos | Ajustar max y windowMs |
| Zod rechaza todo | Esquema muy estricto | Revisar validaciones |
| npm audit falla | Dependencia vulnerable | npm audit fix o actualizar |
Proximo paso
-> RAG con Documentos PDF - Nivel Master