Skip to content

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áginaURL pedida¿Mismo origen?
https://tienda.comhttps://tienda.com/api/productos✅ Sí
https://tienda.comhttp://tienda.com/api❌ Esquema distinto
https://tienda.comhttps://api.tienda.com❌ Subdominio distinto
http://localhost:5173http://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-Type de formulario. El navegador la envía y luego decide si deja que JavaScript lea la respuesta.
  • Preflight: si usas PUT, DELETE, Content-Type: application/json o cabeceras personalizadas (como Authorization), el navegador primero envía una petición OPTIONS preguntando "¿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: 600

Las cabeceras de CORS ​

CabeceraSignificado
Access-Control-Allow-OriginOrigen permitido (uno solo, o *)
Access-Control-Allow-Credentialstrue si se permiten cookies/autenticación
Access-Control-Allow-MethodsMétodos permitidos (en preflight)
Access-Control-Allow-HeadersCabeceras permitidas (en preflight)
Access-Control-Max-AgeSegundos que el navegador recuerda el preflight
Vary: OriginIndica 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íctima

Esto 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 cors
js
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 ​

  1. ¿Por qué esta validación es insegura: if (preg_match('/tienda\.com$/', $origen))?
  2. Configura cors en Express para que solo https://panel.miempresa.mx pueda llamar a /api con cookies.
  3. 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
  1. Acepta https://atacantetienda.com (termina en tienda.com) y no valida el esquema (http:// también pasaría). Usa una lista de orígenes completos y comparación exacta con in_array($origen, $lista, true).

  2. Configuración:

js
app.use('/api', cors({
  origin: ['https://panel.miempresa.mx'], // cors acepta un array y compara exacto
  credentials: true,
}));
  1. 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 Origin con Allow-Credentials: true.
  • * solo para datos públicos sin credenciales.
  • Añade Vary: Origin y, en desarrollo, prefiere el proxy del bundler.

Academia Web — aprende, copia y construye.