Tema
Helmet, CORS y rate limit
Tres middlewares que toda API en producción debería tener desde el primer día:
- helmet: añade cabeceras HTTP de seguridad (contra clickjacking, sniffing de MIME, etc.).
- cors: decide qué dominios pueden llamar a tu API desde el navegador.
- express-rate-limit: limita cuántas peticiones puede hacer una IP en un periodo (frena fuerza bruta y abusos).
Instalación
bash
npm install helmet cors express-rate-limitenv
CORS_ORIGINS=http://localhost:5173,https://www.minegocio.mx,https://admin.minegocio.mxConfiguración centralizada
js
import helmet from 'helmet';
import cors from 'cors';
import rateLimit from 'express-rate-limit';
// ---------- CORS ----------
const origenes = (process.env.CORS_ORIGINS ?? '').split(',').map((o) => o.trim()).filter(Boolean);
export const corsMiddleware = cors({
origin(origin, callback) {
// Sin "origin" = peticiones del mismo servidor, curl, Postman o apps móviles
if (!origin || origenes.includes(origin)) return callback(null, true);
callback(new Error(`Origen no permitido por CORS: ${origin}`));
},
credentials: true, // permite cookies (necesario para refresh tokens)
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
allowedHeaders: ['Content-Type', 'Authorization'],
maxAge: 86400, // cachea el preflight 24 h
});
// ---------- Helmet ----------
export const helmetMiddleware = helmet({
// Content Security Policy: de dónde se pueden cargar scripts, estilos, imágenes...
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'", 'https://fonts.googleapis.com'],
fontSrc: ["'self'", 'https://fonts.gstatic.com'],
imgSrc: ["'self'", 'data:', 'https:'],
connectSrc: ["'self'"],
},
},
crossOriginResourcePolicy: { policy: 'cross-origin' }, // permite mostrar /uploads en otro dominio
});
// ---------- Rate limit ----------
const mensaje = (texto) => ({ error: texto });
/** General: 300 peticiones cada 15 minutos por IP */
export const limiteGeneral = rateLimit({
windowMs: 15 * 60 * 1000,
limit: 300,
standardHeaders: 'draft-7', // envía RateLimit-* para que el cliente sepa cuánto le queda
legacyHeaders: false,
message: mensaje('Demasiadas peticiones, intenta en unos minutos'),
});
/** Login/registro: 5 intentos fallidos cada 15 minutos */
export const limiteAuth = rateLimit({
windowMs: 15 * 60 * 1000,
limit: 5,
skipSuccessfulRequests: true, // sólo cuentan los intentos fallidos
standardHeaders: 'draft-7',
legacyHeaders: false,
message: mensaje('Demasiados intentos de inicio de sesión. Espera 15 minutos.'),
});
/** Formularios públicos (contacto, reservas): 10 por hora */
export const limiteFormularios = rateLimit({
windowMs: 60 * 60 * 1000,
limit: 10,
message: mensaje('Has enviado demasiados formularios. Intenta más tarde.'),
});Aplicarlo en app.js
js
import express from 'express';
import { corsMiddleware, helmetMiddleware, limiteGeneral, limiteAuth, limiteFormularios } from './middlewares/seguridad.js';
const app = express();
app.set('trust proxy', 1); // IMPRESCINDIBLE detrás de Nginx/Render/Cloudflare para que el límite use la IP real
app.disable('x-powered-by'); // no anuncies que usas Express
app.use(helmetMiddleware);
app.use(corsMiddleware);
app.use(express.json({ limit: '100kb' })); // limita el tamaño del cuerpo
app.use('/api', limiteGeneral);
// Límites específicos ANTES de las rutas
app.use('/api/auth/login', limiteAuth);
app.use('/api/auth/registro', limiteAuth);
app.use('/api/contacto', limiteFormularios);
// ... rutasChecklist de seguridad para tu API
| ✔ | Medida |
|---|---|
| ☐ | HTTPS en producción (Let's Encrypt / Cloudflare) |
| ☐ | helmet() activo y x-powered-by deshabilitado |
| ☐ | CORS con lista blanca, nunca origin: '*' con credenciales |
| ☐ | Rate limit general y uno estricto en login |
| ☐ | Límite de tamaño del body (express.json({ limit })) |
| ☐ | Validación de entrada con zod |
| ☐ | Consultas preparadas (sin concatenar SQL) |
| ☐ | Contraseñas con bcrypt/argon2 |
| ☐ | Errores sin stack trace en producción (middleware de errores) |
| ☐ | .env fuera de Git y secretos largos |
| ☐ | npm audit periódico y dependencias actualizadas |
Error común
Olvidar app.set('trust proxy', 1) detrás de un proxy. Todas las peticiones parecen venir de la misma IP (la del proxy), así que el rate limit bloquea a todos tus usuarios a la vez.
Varios servidores
express-rate-limit guarda los contadores en memoria. Si ejecutas varias instancias (PM2 en modo cluster, varios contenedores), usa un store compartido como rate-limit-redis para que el límite sea global.