Tema
CORS
Si alguna vez viste en la consola el error "has been blocked by CORS policy", ya conoces CORS. La reacción típica es buscar en internet y copiar Access-Control-Allow-Origin: *... lo que a veces abre un agujero de seguridad. En esta lección entenderás qué es CORS, por qué existe y cómo configurarlo correctamente en PHP y Express.
Concepto
La política del mismo origen
Los navegadores aplican una regla llamada Same-Origin Policy: el JavaScript de una página solo puede leer respuestas de su mismo origen. Un origen es la combinación de esquema + dominio + puerto:
| URL de la página | URL pedida | ¿Mismo origen? |
|---|---|---|
https://tienda.com | https://tienda.com/api/productos | ✅ Sí |
https://tienda.com | http://tienda.com/api | ❌ Esquema distinto |
https://tienda.com | https://api.tienda.com | ❌ Subdominio distinto |
http://localhost:5173 | http://localhost:3000 | ❌ Puerto distinto |
Sin esta regla, cualquier página que visitaras podría hacer fetch('https://tubanco.com/movimientos') con tus cookies y leer tus datos bancarios.
¿Qué es CORS entonces?
CORS (Cross-Origin Resource Sharing) es el mecanismo para relajar esa regla de forma controlada. Tu servidor responde con cabeceras que dicen: "este origen concreto tiene permiso para leer mis respuestas".
Es como el portero de un edificio con una lista de invitados: la política del mismo origen dice "solo entran residentes"; CORS es la lista de visitantes autorizados que el residente (tu servidor) le entrega al portero (el navegador).
CORS no protege tu servidor
CORS lo aplica el navegador. Herramientas como curl, Postman o un script en Python ignoran CORS por completo. CORS evita que otras páginas web lean respuestas usando las cookies de tus usuarios; no sustituye la autenticación ni la autorización de tu API.
Peticiones simples y preflight
- Petición simple: GET o POST con cabeceras básicas y
Content-Typede formulario. El navegador la envía y luego decide si deja que JavaScript lea la respuesta. - Preflight: si usas PUT, DELETE,
Content-Type: application/jsono cabeceras personalizadas (comoAuthorization), el navegador primero envía una peticiónOPTIONSpreguntando "¿me dejas?". Solo si la respuesta lo autoriza, envía la petición real.
txt
→ OPTIONS /api/productos
Origin: https://admin.tienda.com
Access-Control-Request-Method: DELETE
Access-Control-Request-Headers: content-type, authorization
← 204 No Content
Access-Control-Allow-Origin: https://admin.tienda.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Max-Age: 600Las cabeceras de CORS
| Cabecera | Significado |
|---|---|
Access-Control-Allow-Origin | Origen permitido (uno solo, o *) |
Access-Control-Allow-Credentials | true si se permiten cookies/autenticación |
Access-Control-Allow-Methods | Métodos permitidos (en preflight) |
Access-Control-Allow-Headers | Cabeceras permitidas (en preflight) |
Access-Control-Max-Age | Segundos que el navegador recuerda el preflight |
Vary: Origin | Indica a cachés y CDNs que la respuesta cambia según el origen |
Los errores peligrosos
1. Reflejar cualquier origen con credenciales
php
<?php
// ❌ MUY PELIGROSO: acepta cualquier origen Y envía cookies
header('Access-Control-Allow-Origin: ' . $_SERVER['HTTP_ORIGIN']);
header('Access-Control-Allow-Credentials: true');
// Ahora sitio-malo.com puede leer /api/mi-perfil con la sesión de la víctimaEsto anula por completo la política del mismo origen. Es un hallazgo frecuente en auditorías.
2. Validar el origen con str_contains o regex flojas
js
// ❌ "https://tienda.com.atacante.net" y "https://mitienda.com" también pasan
if (origin.includes('tienda.com')) { /* permitir */ }
// ❌ El punto sin escapar acepta "https://tiendaXcom"
/^https:\/\/tienda.com$/.test(origin);3. Permitir el origen null
Páginas en iframe con sandbox o archivos locales envían Origin: null. Un atacante puede generarlo a propósito. Nunca pongas null en tu lista.
¿Y el comodín *?
Access-Control-Allow-Origin: * es seguro para datos públicos sin autenticación (una API de tipos de cambio, un catálogo público). El navegador prohíbe combinar * con Allow-Credentials: true, así que no expone sesiones. El problema es reflejar el origen dinámicamente con credenciales.
Ejemplo: CORS correcto en PHP
php
<?php
declare(strict_types=1);
// Lista blanca EXACTA de orígenes (desde .env en proyectos reales)
const ORIGENES_PERMITIDOS = [
'https://tienda.com',
'https://admin.tienda.com',
'http://localhost:5173', // solo en desarrollo: quítalo en producción
];
function aplicarCors(): void
{
$origen = $_SERVER['HTTP_ORIGIN'] ?? '';
// Siempre: la respuesta varía según el origen (para cachés/CDN)
header('Vary: Origin');
// ✅ Comparación exacta con la lista
if (in_array($origen, ORIGENES_PERMITIDOS, true)) {
header("Access-Control-Allow-Origin: $origen");
header('Access-Control-Allow-Credentials: true');
}
// Respuesta al preflight
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
header('Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE');
header('Access-Control-Allow-Headers: Content-Type, Authorization, X-CSRF-Token');
header('Access-Control-Max-Age: 600');
http_response_code(204);
exit;
}
}php
<?php
require __DIR__ . '/../cors.php';
aplicarCors(); // antes de cualquier salida
header('Content-Type: application/json; charset=utf-8');
echo json_encode(['productos' => [/* ... */]]);Ejemplo: CORS correcto en Express
bash
npm install corsjs
import express from 'express';
import cors from 'cors';
const app = express();
// Lista desde variable de entorno: CORS_ORIGINS=https://tienda.com,https://admin.tienda.com
const permitidos = (process.env.CORS_ORIGINS ?? '').split(',').map(s => s.trim()).filter(Boolean);
const opcionesCors = {
origin(origin, callback) {
// Peticiones sin Origin (curl, apps móviles, mismo origen): no aplican CORS
if (!origin) return callback(null, true);
// ✅ Comparación exacta
if (permitidos.includes(origin)) return callback(null, true);
// Origen no autorizado: no añadimos cabeceras CORS
return callback(null, false);
},
credentials: true, // permite cookies de sesión
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
allowedHeaders: ['Content-Type', 'Authorization', 'X-CSRF-Token'],
maxAge: 600,
};
// Solo aplicamos CORS a la API, no a todo el sitio
app.use('/api', cors(opcionesCors));
app.get('/api/productos', (req, res) => {
res.json({ productos: [] });
});
// API pública sin cookies: comodín sin credenciales
app.get('/public/tipo-cambio', cors(), (req, res) => {
res.json({ usd_mxn: 18.2 });
});js
// Desde https://admin.tienda.com
const respuesta = await fetch('https://api.tienda.com/api/productos', {
credentials: 'include', // envía cookies; requiere Allow-Credentials en el servidor
});
const datos = await respuesta.json();Error común
"Arreglar" el error de CORS en desarrollo con app.use(cors()) y olvidarlo así en producción. Para desarrollo, una alternativa mejor es usar el proxy de tu herramienta (por ejemplo server.proxy en Vite): el navegador cree que todo viene del mismo origen y no necesitas CORS.
js
// vite.config.js — en desarrollo, /api se reenvía al backend sin CORS
export default {
server: {
proxy: {
'/api': 'http://localhost:3000',
},
},
};Cookies entre dominios
Si tu frontend (app.tienda.com) y tu API (api.tienda.com) comparten dominio raíz, son el mismo sitio (same-site) aunque sean orígenes distintos: las cookies SameSite=Lax funcionan con credentials: 'include'. Si están en dominios totalmente distintos (tienda.com y mi-api.herokuapp.com), necesitarías SameSite=None; Secure, que reabre la puerta a CSRF y además muchos navegadores bloquean cookies de terceros. Lo más simple: pon la API en un subdominio propio o detrás del mismo dominio (tienda.com/api).
Ejercicios
- ¿Por qué esta validación es insegura:
if (preg_match('/tienda\.com$/', $origen))? - Configura
corsen Express para que solohttps://panel.miempresa.mxpueda llamar a/apicon cookies. - Tu API devuelve datos públicos de horarios de una sucursal y la consume cualquier sitio. ¿Qué configuración de CORS es adecuada?
Ver solución
Acepta
https://atacantetienda.com(termina entienda.com) y no valida el esquema (http://también pasaría). Usa una lista de orígenes completos y comparación exacta conin_array($origen, $lista, true).Configuración:
js
app.use('/api', cors({
origin: ['https://panel.miempresa.mx'], // cors acepta un array y compara exacto
credentials: true,
}));Access-Control-Allow-Origin: *sin credenciales. Al no usar cookies, no hay datos privados que proteger:
js
app.get('/api/horarios', cors({ origin: '*' }), (req, res) => {
res.json({ lunes: '9:00-18:00' });
});Resumen
- La política del mismo origen impide que otras páginas lean tus respuestas; CORS la relaja de forma controlada.
- Un origen = esquema + dominio + puerto.
- CORS lo aplica el navegador: no reemplaza autenticación ni autorización.
- Usa una lista blanca exacta de orígenes; nunca reflejes
OriginconAllow-Credentials: true. *solo para datos públicos sin credenciales.- Añade
Vary: Originy, en desarrollo, prefiere el proxy del bundler.