← Volver al blog
CORSNode.jsNext.jsSeguridad de APIExpressCSRFOWASPSeguridad web

Mala configuración de CORS en Node.js y Next.js: por qué reflejar el origen rompe tu API [2026]

La mala configuración de CORS es invisible en desarrollo

La mayoría de los bugs de seguridad de API rompen algo. La mala configuración de CORS no rompe nada que puedas ver. El frontend llama a la API, el navegador está contento, tus tests de integración pasan, y la vulnerabilidad se queda ahí, callada: sitios web de terceros arbitrarios pueden leer las respuestas autenticadas de tu API — datos de perfil, facturas, tokens, mensajes privados — usando las cookies de sesión que el navegador adjunta automáticamente.

Nunca aparece en tus propias pruebas porque tu propio origen siempre está permitido. Aparece en un informe de bug bounty, o en una línea de log que nunca leíste, cuando alguien apunta un origen malicioso a tu API usando el navegador de la víctima como proxy.

Este artículo cubre los cinco patrones que la producen, los dos hechos sobre CORS que la mayoría de los equipos malinterpreta y la lista de permitidos por coincidencia exacta que cierra la clase entera.

Qué protege realmente CORS — y qué no

Tres cosas que interiorizar antes del código:

  1. CORS lo aplica el navegador, y solo el navegador. curl, un script de Python, una app móvil y los servidores de tus competidores lo ignoran por completo. Una política de CORS permisiva no es control de acceso — es una instrucción dirigida a los navegadores sobre qué sitios pueden leer las respuestas.
  2. Access-Control-Allow-Origin es un límite de lectura, no un límite de ejecución. Cuando una petición cruza la red, tu servidor ya la procesó y ya ejecutó sus efectos secundarios. CORS solo decide si el JavaScript de la página que llama puede ver la respuesta. Bloquear la lectura nunca deshace un cambio de estado.
  3. Las peticiones simples se saltan el preflight. Un GET, o un POST con Content-Type: text/plain, application/x-www-form-urlencoded o multipart/form-data y sin cabeceras personalizadas, se envía directamente a tu servidor con las cookies del usuario. Sin petición OPTIONS, sin comprobación de CORS antes de que ocurra la escritura.

Ese tercer hecho es la razón por la que CORS no es protección CSRF, y el segundo es la razón por la que CORS no es autenticación. Ten ambos presentes mientras lees las malas configuraciones de abajo.

Los cinco patrones que la provocan

1. Reflejar cualquier origen mientras se envían credenciales

Este es el catastrófico, y es una línea:

js
import cors from 'cors';
import express from 'express';

const app = express();

// origin: true reflects whatever Origin the caller sent; credentials: true
// tells the browser to include cookies and to hand the response body to the page.
app.use(cors({ origin: true, credentials: true }));

Ahora https://evil.com envía un fetch a tu API desde el navegador de una víctima con sesión iniciada, recibe de vuelta Access-Control-Allow-Origin: https://evil.com con Access-Control-Allow-Credentials: true, y lee la respuesta completa. Ojo: los navegadores rechazan Access-Control-Allow-Origin: * junto con credenciales — los desarrolladores suelen "arreglar" ese rechazo cambiando a la reflexión, que es estrictamente peor porque convierte una petición bloqueada en una fuga de datos exitosa.

2. Listas de permitidos con subcadenas y regex sin anclar

Tratar el origen como una cadena que contiene una subcadena es el segundo hallazgo más común:

js
const ALLOWED = ['https://app.example.com'];

app.use(cors({
  origin(origin, callback) {
    // 'https://app.example.com' — but also every one of these:
    //   https://app.example.com.evil.com     (startsWith passes)
    //   notexample.com                        (endsWith('.example.com') passes)
    //   https://evil.com/?x=.example.com      (includes() passes)
    if (!origin || ALLOWED.some((a) => origin.startsWith(a))) {
      return callback(null, true);
    }
    callback(new Error('Not allowed'));
  },
  credentials: true,
}));

El mismo bug aparece con regex que no está anclada o no está escapada: /example\.com/ coincide con https://example.com.attacker.net, y una regex aplicada a texto del usuario es también una superficie de ReDoS. Nunca valides un origen con startsWith, includes, endsWith, split ni una regex sin anclar.

3. Confiar en el origen null

Los iframes en sandbox, las páginas file:// y algunos flujos de redirección envían Origin: null. Si null está en tu lista de permitidos — normalmente añadido para "que funcione el testing local" — al atacante le basta con renderizar un iframe en sandbox en cualquier página que controle, con sandbox="allow-scripts", y el navegador enviará Origin: null con las cookies de la víctima y dejará que la página de ataque lea la respuesta. null nunca debe estar permitido en producción.

4. Subdominios comodín con credenciales

js
// Every subdomain is now a fully privileged origin for your API.
const SUBDOMAIN = /^https:\/\/[a-z0-9-]+\.example\.com$/;
app.use(cors({ origin: (o, cb) => cb(null, !!o && SUBDOMAIN.test(o)), credentials: true }));

Esto parece ordenado y colapsa tu modelo de seguridad a "el subdominio más débil". Un XSS almacenado en un sitio de marketing, un host de despliegues de preview, un subdominio de staging olvidado o un registro DNS pendiente apuntando a un SaaS ya desmantelado (subdomain takeover) se convierten en una lectura cross-origin autenticada de tu API. Permite orígenes exactos, no formas de origen, salvo que controles y monitorices de verdad todos los subdominios — y en ese caso sigues necesitando un inventario y monitorización de takeovers.

5. Publicar en producción la configuración de desarrollo

http://localhost:3000 y http://localhost:5173 dejados en la lista de permitidos de producción, * combinado con credentials: true, o una lista de permitidos alimentada por una variable .env que está vacía en producción — y una lista vacía que significa "permitir todo" en lugar de "no permitir nada". El deny-by-default tiene que ser la ruta de código, no el fallback.

El patrón correcto: coincidencia exacta, denegar por defecto, Vary

ts
import type { RequestHandler } from 'express';

const ALLOWED_ORIGINS = new Set(
  (process.env.CORS_ALLOWED_ORIGINS ?? '')
    .split(',')
    .map((s) => s.trim())
    .filter(Boolean),
);

export const corsMiddleware: RequestHandler = (req, res, next) => {
  const origin = req.headers.origin;

  // The response body/headers differ per origin: caches must key on it.
  res.setHeader('Vary', 'Origin');

  // Exact string match on the full origin (scheme + host + port). No substring,
  // no regex, no case-insensitive comparison.
  if (origin && ALLOWED_ORIGINS.has(origin)) {
    res.setHeader('Access-Control-Allow-Origin', origin);
    res.setHeader('Access-Control-Allow-Credentials', 'true');
    res.setHeader('Access-Control-Allow-Methods', 'GET,POST,PATCH,DELETE');
    res.setHeader('Access-Control-Allow-Headers', 'Content-Type,Authorization');
    res.setHeader('Access-Control-Max-Age', '600');
  }
  // Unknown origin: emit no CORS headers at all. The browser blocks the read,
  // the server still processed the request, and nothing was widened.

  if (req.method === 'OPTIONS') {
    res.status(204).end();
    return;
  }
  next();
};

Cinco detalles que importan:

  • Set.has sobre el origen literal. Un origen es scheme://host:port. Compáralo exactamente, desde configuración, y haz que la configuración sea exhaustiva en lugar de ingeniosa.
  • Denegar por defecto significa silencio. Para un origen desconocido, no emitas ninguna cabecera Access-Control-Allow-Origin. No la reflejes, no caigas a * y no lances un 500 desde el callback de CORS — algunas librerías lo hacen, lo que convierte una lectura bloqueada en una huella de errores de tu stack.
  • Nunca devuelvas Access-Control-Request-Headers ni Access-Control-Request-Method. Codifica de forma fija las listas que realmente soportas; devolverlas es un sink de reflexión con la misma forma que la reflexión de origen.
  • Vary: Origin es obligatorio en el momento en que la cabecera ACAO es dinámica y algo entre el usuario y tu aplicación cachea (CDN, proxy inverso, runtime en el edge). Sin él, una caché puede almacenar la cabecera permisiva calculada para un origen y entregársela a otro distinto — envenenamiento de la política de CORS, servido por tu propia infraestructura.
  • Access-Control-Max-Age cambia riesgo de corrección por latencia. Un max-age largo significa que un navegador sigue honrando un origen que después quitaste de la lista. Diez minutos es un valor por defecto razonable; un día no lo es.

CORS no es protección CSRF

El malentendido más caro: "nuestra API tiene CORS configurado, así que estamos protegidos frente a peticiones cross-site". No lo estás. Como las peticiones simples se envían sin preflight (el tercer hecho de arriba), una página maliciosa puede hacer POST de un cuerpo codificado como formulario a tu endpoint autenticado por cookie y el cambio de estado ocurre — el atacante simplemente no puede leer la respuesta. Para endpoints de escritura, eso suele ser lo único que importa.

Así que mantén las defensas reales en las rutas autenticadas por cookie que cambian estado: SameSite=Lax (o Strict) en las cookies de sesión, más tokens CSRF (double-submit o del lado del servidor, ligados a la sesión) en todo lo que mute estado. CORS estrecha la superficie de lectura; no autentica a quien escribe. Consulta nuestra guía de autenticación y gestión de sesiones para los flags de cookies y los patrones de tokens que acompañan a esto.

Y nunca hagas depender la autorización del lado del servidor de CORS. Si tu "API interna" solo está protegida por una política de CORS restrictiva, cualquier invocador que no sea un navegador — el backend de un socio, un script filtrado, curl — ya tiene acceso completo. Autentica a los invocadores de servidor con API keys o credenciales de cliente OAuth, y autorízalos en el servidor, exactamente igual que haces con los usuarios de navegador.

Las particularidades de Next.js

  • headers() en next.config.js es estático. No puede calcular una lista de orígenes permitidos por petición. Si necesitas una lista dinámica, hazlo en middleware.ts o en el route handler — no en la configuración estática.
  • El middleware cubre route handlers y rutas de API. Una lista dinámica en middleware.ts es un punto de estrangulamiento único legítimo, siempre que se ejecute antes del handler y aplique a todas las rutas /api/*.
  • No pongas Access-Control-Allow-Origin: * en /api/* en una app con NextAuth o cookies de sesión. Ese es el bug de comodín con cookies con una etiqueta de framework encima.
  • Los Server Components y las Server Actions no son relevantes para CORS — se ejecutan en el servidor y los invoca la maquinaria interna de Next.js, no JavaScript cross-origin del navegador. Su protección es la autenticación y las comprobaciones de CSRF/origen en las actions, no las cabeceras ACAO.

Verifícalo en cinco minutos

bash
# A rogue origin must NOT receive an Access-Control-Allow-Origin header.
curl -sI -H 'Origin: https://evil.com' https://api.example.com/api/me \
  | grep -i '^access-control'

# Your real frontend must receive exactly its own origin back.
curl -sI -H 'Origin: https://app.example.com' https://api.example.com/api/me \
  | grep -i '^access-control'

Después hazlo permanente: un test en CI que afirme que un origen malicioso no recibe la cabecera ACAO, junto a otro test para el origen permitido. Las regresiones de CORS son silenciosas, así que la aserción es lo único que las detecta.

La checklist de arranque

  • [ ] No hay origin: true, ni reflexión de origen, ni * combinado con credentials: true en ningún punto del código.
  • [ ] La lista de orígenes permitidos es una coincidencia exacta sobre scheme + host + puerto, cargada desde configuración, nunca construida a partir de entrada del usuario.
  • [ ] Origen desconocido → no se emite ninguna cabecera CORS (denegar por defecto) y nunca un 500 desde el callback de CORS.
  • [ ] El origen null no está permitido; localhost y los orígenes de desarrollo están fuera de la configuración de producción.
  • [ ] Vary: Origin está en todas las respuestas cuya cabecera ACAO es dinámica; las claves de caché del CDN/edge incluyen Origin.
  • [ ] No hay listas de subdominios comodín con credenciales; los subdominios están inventariados y los registros DNS pendientes se monitorizan por si hay takeover.
  • [ ] Las rutas autenticadas por cookie que cambian estado tienen cookies SameSite más tokens CSRF; CORS no se trata como protección CSRF.
  • [ ] La autorización del lado del servidor no depende de CORS; API keys u OAuth protegen a los invocadores que no son navegadores.
  • [ ] Access-Control-Request-Headers y -Method nunca se devuelven; las cabeceras y métodos soportados están codificados de forma fija.
  • [ ] Un test de CI afirma que un origen malicioso no recibe la cabecera Access-Control-Allow-Origin.

Qué hacer esta semana

Busca en el código cors(, origin:, Access-Control-Allow-Origin, credentials: true y cualquier lista de permitidos construida con startsWith/includes/endsWith o una regex sin anclar. Cada coincidencia es un hallazgo candidato. Después ejecuta los dos comandos curl de arriba contra producción con un origen malicioso — la segunda vez que lo hagas, intégralo en CI. La mala configuración de CORS nunca rompe tu propia aplicación; solo expone a tus usuarios, que es precisamente por lo que sobrevive tanto tiempo en producción.

¿Quieres una revisión profesional de la política de CORS, los flags de cookies y las defensas CSRF de tu API? Agenda una auditoría de seguridad — probamos tus endpoints desde el origen de un atacante, exactamente como lo haría un cazador de bug bounties.

Comparte este artículo:TwitterLinkedIn
JS

JS Security Audit

Auditorías dirigidas por un ingeniero de seguridad JavaScript senior con más de 10 años de experiencia.