Configuración incorrecta de CORS (CWE-942): cómo el reflejo de Origin filtra datos y cómo corregirlo
Una API que responde a cualquier Origin con Access-Control-Allow-Origin igual a ese origen y Access-Control-Allow-Credentials: true permite que cualquier sitio web lea sus respuestas con las cookies del visitante. Cómo las comprobaciones de sufijo, el origen null y los orígenes reflejados rompen la política del mismo origen, y la lista de permitidos con coincidencia exacta que lo corrige, con un ejemplo en Express y cors.
Explicación en Lenguaje Sencillo (ELI5)
Un cajero de banco lee tu saldo en voz alta a quien tú envíes, siempre que el mensajero muestre una carta que diga para quién trabaja y la lista del cajero apruebe ese nombre. La regla de un cajero es 'aprobar cualquier nombre que termine en la palabra García'. Un desconocido envía un mensajero con una carta de 'Pérez García', el cajero la aprueba y tu saldo se lee a alguien a quien nunca enviaste. El reflejo de Origin es la versión perezosa en la que el cajero aprueba todos los nombres. La solución es una lista corta y exacta de nombres, comparados letra por letra.
Conceptos Clave y Términos
- Política del mismo origen
- Los navegadores solo permiten que un script lea respuestas de su propio origen (esquema, host y puerto). Las cabeceras de respuesta CORS son la forma en que un servidor relaja esa regla a propósito para orígenes concretos.
- Access-Control-Allow-Origin con credenciales
- Si la respuesta nombra el origen de quien llama en
Access-Control-Allow-Originy envíaAccess-Control-Allow-Credentials: true, un script en ese origen puede leer respuestas a peticiones que llevaron las cookies del usuario. Los navegadores rechazan*junto con credenciales, por eso los servidores mal configurados devuelven el origen tal cual. - Reflejo de Origin y comprobación de sufijo
- Copiar la cabecera
Originen la respuesta confía en todos los sitios. Comprobaciones comoendsWith('example.com'), búsquedas de subcadenas o expresiones regulares sin anclas y con puntos sin escapar también aceptanhttps://attacker-example.com. - El origen null
- Los iframes en sandbox, las URL
data:y algunas redirecciones envíanOrigin: null. Cualquier atacante puede producirlo, así que permitirnullequivale a permitir a todo el mundo. - Vary: Origin
- Cuando
Access-Control-Allow-Origindepende de la petición, la respuesta debe llevarVary: Originpara que las cachés compartidas no sirvan a un origen las cabeceras CORS de otro. El paquetecorsla añade para orígenes dinámicos.
Flujo de Ataque Paso a Paso
La víctima tiene sesión en el sitio de la API
Su navegador guarda una cookie de sesión de api.example.com que se envía en peticiones entre sitios.
Abre una página en el dominio del atacante
Un script llama a fetch('https://api.example.com/api/account', { credentials: 'include' }). Una página en https://attacker-example.com que solo muestra en la consola la longitud de la respuesta ilustra el caso.
La API aprueba el origen
La comprobación de sufijo acepta attacker-example.com, así que la respuesta lleva ese origen en Access-Control-Allow-Origin junto con Access-Control-Allow-Credentials: true.
El script lee datos privados
El navegador entrega el JSON de la cuenta al script del atacante, que puede enviarlo a cualquier parte. En 2016 James Kettle encontró este patrón en varios exchanges de Bitcoin, donde exponía claves de API y datos de monederos.
Código Fuente: Vulnerable vs. Seguro
// server.js: cualquier origen que termine en example.com, más null, puede leer con cookies
import express from 'express';
import cors from 'cors';
import { accountFor, requireSession } from './account.js';
const app = express();
// true hace que cors devuelva el Origin de quien llama junto con credenciales
app.use(cors({
origin: (origin, callback) => callback(null, !origin || origin === 'null' || origin.endsWith('example.com')),
credentials: true,
}));
// datos privados de la cuenta legibles por los orígenes aprobados
app.get('/api/account', requireSession, (req, res) => {
res.json(accountFor(req.session.userId));
});
// server.js: dos orígenes exactos, nada más
import express from 'express';
import cors from 'cors';
import { accountFor, requireSession } from './account.js';
const app = express();
// orígenes completos con esquema y host, comparados exactamente
const ALLOWED_ORIGINS = new Set(['https://app.example.com', 'https://admin.example.com']);
// un Origin desconocido o ausente no recibe cabeceras CORS; cors añade Vary: Origin
app.use(cors({
origin: (origin, callback) => callback(null, origin !== undefined && ALLOWED_ORIGINS.has(origin)),
credentials: true,
methods: ['GET', 'POST'],
maxAge: 600,
}));
// los datos personales nunca se guardan en cachés compartidas
app.get('/api/account', requireSession, (req, res) => {
res.set('Cache-Control', 'no-store');
res.json(accountFor(req.session.userId));
});
Lista de Verificación de Seguridad para Ingeniería
- Mantén una lista de permitidos con coincidencia exacta de orígenes completos (esquema, host y puerto) y compara con una búsqueda en un
Set; nunca reflejesOriginni uses comprobaciones de sufijo, subcadena o regex sin anclas. - No permitas nunca el origen
nullni permitas credenciales para un origen que no controlas. - Envía
Vary: Originsiempre queAccess-Control-Allow-Originse elija por petición, yCache-Control: no-storeen respuestas con datos personales. - Configura las cookies de sesión como
SameSite=LaxoStrictpara que las peticiones entre sitios no las lleven. - Añade una prueba en CI que envíe
Origin: https://attacker.exampleyOrigin: nulla rutas autenticadas y falle si vuelveAccess-Control-Allow-Origin.