Tema
CORS y seguridad
Una API pública en internet recibe ataques automatizados desde el primer día. En esta lección configurarás CORS correctamente y aplicarás las defensas esenciales: helmet, rate limiting, límites de tamaño, validación y manejo seguro de errores. Para profundizar, revisa el curso de Seguridad web.
Concepto: ¿qué es CORS?
Por seguridad, el navegador aplica la política del mismo origen: una página en https://mitienda.com no puede leer respuestas de https://api.otro.com a menos que esa API lo permita explícitamente. Un origen = protocolo + dominio + puerto.
| Página | API | ¿Mismo origen? |
|---|---|---|
http://localhost:5173 | http://localhost:3000 | ❌ (puerto distinto) |
https://app.com | https://app.com/api | ✅ |
https://app.com | https://api.app.com | ❌ (subdominio distinto) |
CORS (Cross-Origin Resource Sharing) es el mecanismo con el que tu API dice "este origen sí puede leerme", mediante headers como Access-Control-Allow-Origin.
Para peticiones "no simples" (con Content-Type: application/json, PUT, DELETE, cookies...) el navegador primero envía una petición preflight OPTIONS para preguntar si está permitido.
Importante
CORS lo aplica el navegador. Postman, curl u otro servidor ignoran CORS. Por eso CORS no es una medida de autenticación: protege a los usuarios del navegador, no a tu API.
El error que verás
txt
Access to fetch at 'http://localhost:3000/api/productos' from origin 'http://localhost:5173'
has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present.Configurar CORS con el paquete cors
bash
npm i cors helmet express-rate-limitjs
// src/config/cors.js
import cors from 'cors'
// Lista blanca desde variable de entorno: "https://mitienda.com,https://admin.mitienda.com"
const permitidos = (process.env.CORS_ORIGIN ?? 'http://localhost:5173')
.split(',')
.map((o) => o.trim())
export const corsMiddleware = cors({
origin(origin, callback) {
// Peticiones sin origen (curl, apps móviles, server-to-server) se permiten
if (!origin || permitidos.includes(origin)) return callback(null, true)
callback(new Error(`Origen no permitido por CORS: ${origin}`))
},
credentials: true, // permite cookies (requiere origen explícito)
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
allowedHeaders: ['Content-Type', 'Authorization'],
maxAge: 600 // cachea el preflight 10 minutos
})Error común
Usar origin: '*' junto con credentials: true. El navegador lo rechaza: con cookies debes indicar orígenes concretos. Y * en una API privada es mala idea de todos modos.
helmet: headers de seguridad
helmet agrega de una vez una docena de headers HTTP que protegen contra ataques comunes:
| Header | Protege contra |
|---|---|
Content-Security-Policy | XSS: limita de dónde se cargan scripts |
Strict-Transport-Security | Fuerza HTTPS |
X-Content-Type-Options: nosniff | Que el navegador "adivine" tipos de archivo |
X-Frame-Options / frame-ancestors | Clickjacking (meter tu sitio en un iframe) |
Referrer-Policy | Fuga de URLs a otros sitios |
Además quita X-Powered-By: Express, que revela tu tecnología.
js
import helmet from 'helmet'
app.use(helmet())
// Si sirves un frontend y cargas recursos externos, ajusta la CSP:
app.use(
helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
imgSrc: ["'self'", 'data:', 'https://res.cloudinary.com'],
scriptSrc: ["'self'"]
}
},
crossOriginResourcePolicy: { policy: 'cross-origin' } // si otros dominios muestran tus /uploads
})
)Rate limiting
Limita cuántas peticiones puede hacer una IP en una ventana de tiempo. Frena ataques de fuerza bruta al login, scraping y abuso.
js
// src/config/rateLimit.js
import { rateLimit } from 'express-rate-limit'
// General: 300 peticiones cada 15 minutos por IP
export const limiteGeneral = rateLimit({
windowMs: 15 * 60 * 1000,
limit: 300,
standardHeaders: 'draft-8', // headers RateLimit estándar
legacyHeaders: false,
message: { error: 'Demasiadas peticiones, intenta más tarde' }
})
// Estricto para login/registro: 5 intentos cada 15 min
export const limiteAuth = rateLimit({
windowMs: 15 * 60 * 1000,
limit: 5,
skipSuccessfulRequests: true, // sólo cuentan los intentos fallidos
message: { error: 'Demasiados intentos. Espera 15 minutos.' }
})js
app.use('/api', limiteGeneral)
app.use('/api/auth/login', limiteAuth)Detrás de un proxy
En Render, Railway, Nginx o Cloudflare, la IP real viene en el header X-Forwarded-For. Activa app.set('trust proxy', 1) o todas las peticiones parecerán venir de la misma IP (la del proxy).
Con varios servidores (o PM2 en modo clúster), el contador en memoria no se comparte: usa un almacén como Redis (rate-limit-redis).
App segura completa
js
import express from 'express'
import helmet from 'helmet'
import cookieParser from 'cookie-parser'
import { corsMiddleware } from './config/cors.js'
import { limiteGeneral, limiteAuth } from './config/rateLimit.js'
import authRouter from './routes/auth.routes.js'
import productosRouter from './routes/productos.routes.js'
const app = express()
// 1. Confiar en el proxy (Render, Nginx...) para obtener la IP real
app.set('trust proxy', 1)
// 2. Headers de seguridad y CORS primero
app.use(helmet())
app.use(corsMiddleware)
// 3. Límites
app.use('/api', limiteGeneral)
app.use('/api/auth/login', limiteAuth)
app.use('/api/auth/registro', limiteAuth)
// 4. Parsers con límite de tamaño (evita bodies gigantes)
app.use(express.json({ limit: '100kb' }))
app.use(express.urlencoded({ extended: false, limit: '100kb' }))
app.use(cookieParser())
// 5. Rutas
app.get('/api/salud', (req, res) => res.json({ ok: true }))
app.use('/api/auth', authRouter)
app.use('/api/productos', productosRouter)
// 6. 404 y errores al final
app.use((req, res) => res.status(404).json({ error: 'No encontrado' }))
// eslint-disable-next-line no-unused-vars
app.use((err, req, res, next) => {
if (err.message?.startsWith('Origen no permitido')) {
return res.status(403).json({ error: 'Origen no permitido' })
}
const status = err.status ?? 500
if (status >= 500) console.error(err)
res.status(status).json({
error: status >= 500 && process.env.NODE_ENV === 'production'
? 'Error interno del servidor' // nunca detalles internos en producción
: err.message
})
})
export default appenv
NODE_ENV=development
CORS_ORIGIN=http://localhost:5173,http://localhost:4173Checklist de seguridad para APIs
| Área | Medida |
|---|---|
| Entrada | Validar todo con zod (tipos, longitudes, formatos) |
| SQL/NoSQL | Consultas parametrizadas; nunca concatenar |
| Contraseñas | bcrypt/argon2, nunca texto plano |
| Sesiones | Cookies httpOnly + secure + sameSite; JWT con expiración |
| Autorización | Verifica que el recurso pertenezca al usuario (no sólo que esté autenticado) |
| Headers | helmet() |
| Abuso | Rate limiting, más estricto en login |
| Errores | Sin stack traces ni mensajes de BD en producción |
| Secretos | Variables de entorno, nunca en Git |
| Dependencias | npm audit y actualizaciones regulares |
| Transporte | HTTPS siempre (Let's Encrypt en tu VPS) |
| Logs | Registra intentos fallidos, pero nunca contraseñas ni tokens |
Autorización por recurso (IDOR)
El error de seguridad más común en APIs: verificar que el usuario está autenticado, pero no que el recurso le pertenece.
js
// ❌ Cualquier usuario autenticado puede ver el pedido de otro cambiando el id
router.get('/pedidos/:id', requiereAuth, async (req, res) => {
res.json(await prisma.pedido.findUnique({ where: { id: Number(req.params.id) } }))
})
// ✅ Filtra también por el dueño
router.get('/pedidos/:id', requiereAuth, async (req, res) => {
const pedido = await prisma.pedido.findFirst({
where: { id: Number(req.params.id), usuarioId: req.usuario.id }
})
if (!pedido) return res.status(404).json({ error: 'Pedido no encontrado' })
res.json(pedido)
})Buena práctica
Revisa tu API con la lista OWASP API Security Top 10. Las tres primeras (autorización rota por objeto, autenticación rota y exposición excesiva de datos) causan la mayoría de las filtraciones reales.
Ejercicios
- Configura CORS para que en desarrollo acepte cualquier
localhost(cualquier puerto) y en producción sólohttps://mitienda.com. - Crea un limitador para
POST /api/contactoque permita 3 mensajes por hora por IP y responda con un mensaje en español.
Ver solución
js
// 1
import cors from 'cors'
const esProd = process.env.NODE_ENV === 'production'
export const corsMiddleware = cors({
origin(origin, cb) {
if (!origin) return cb(null, true)
const permitido = esProd
? origin === 'https://mitienda.com'
: /^http:\/\/localhost:\d+$/.test(origin)
cb(permitido ? null : new Error(`Origen no permitido por CORS: ${origin}`), permitido)
},
credentials: true
})js
// 2
import { rateLimit } from 'express-rate-limit'
const limiteContacto = rateLimit({
windowMs: 60 * 60 * 1000,
limit: 3,
standardHeaders: 'draft-8',
legacyHeaders: false,
message: { error: 'Ya enviaste varios mensajes. Intenta de nuevo en una hora.' }
})
app.post('/api/contacto', limiteContacto, (req, res) => {
// validar y enviar email...
res.status(202).json({ ok: true })
})Resumen
- CORS lo aplica el navegador; configura una lista blanca de orígenes y
credentials: truesi usas cookies. helmet()agrega headers de seguridad con una línea.express-rate-limitfrena fuerza bruta; recuerdatrust proxydetrás de un proxy.- Limita el tamaño del body, valida todo, autoriza por recurso y no expongas errores internos.