Implementar una Content Security Policy en un sitio Laravel
Una Content Security Policy le dice al navegador desde qué orígenes puede una página cargar código, estilos, imágenes y fuentes, y adónde puede enviar datos. Es el control que limita el daño cuando algo sí llega a inyectarse, y es la única cabecera de seguridad que puede romper su aplicación si la aplica sin mirar antes.
Esta es la secuencia que usamos en este sitio, en el orden en que la repetiríamos.
Paso 1: decidir de dónde sale la cabecera
Dos opciones, y la elección depende de una sola cosa.
nginx si la política es idéntica en todas las respuestas. Se aplica también a los archivos estáticos y a las páginas de error, PHP nunca se ejecuta y no hay nada que recordar en la aplicación. Ahí es donde vive la nuestra.
Un middleware de Laravel si necesita un valor que cambie por petición; en la práctica, nonces. Un nonce es un token aleatorio generado por respuesta, que se pone en la cabecera y en cada <script> inline, de modo que el navegador ejecuta los bloques inline que usted marcó y rechaza cualquiera que haya inyectado un atacante. Solo la aplicación puede hacerlo, porque las vistas que renderizan los bloques inline necesitan el mismo valor que lleva la cabecera.
Empiece por nginx salvo que vaya directo a los nonces. Moverla a PHP más adelante es un cambio acotado, y mientras tanto tendrá una política que funciona.
Paso 2: escribir la política de partida
La nuestra, directiva por directiva:
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; frame-src 'self' https://cal.com; object-src 'none'; report-uri /csp-report" always;
default-src 'self'es la base. Todo lo que no se nombre abajo recurre a ella, así que cualquier cosa que olvide queda por defecto en el mismo origen.script-srces la directiva que más importa.'unsafe-inline'y'unsafe-eval'la debilitan, y la mayoría de las aplicaciones necesita al menos la primera para empezar. Deshacerse de ellas es el paso 7, no el paso 2.img-src 'self' data::data:cubre los SVG inline y las imágenes generadas. Es un requisito habitual y poco arriesgado.connect-src 'self'impide quefetchyXMLHttpRequestlleguen a otro origen. Junto conimg-src, esto es lo que bloquea la salida de datos robados: la carga tiene que llegar a casa de alguna forma, y cada camino a casa es una petición al servidor de otro.base-uri 'self'impide que una etiqueta<base>inyectada redirija en silencio todas las URL relativas de la página. Barata, y fácil de dejar fuera.form-action 'self'impide que un formulario se redirija a otro origen.frame-ancestors 'none'es el control moderno contra el clickjacking, y la razón por la queX-Frame-Optionses ahora un respaldo para navegadores antiguos.object-src 'none'elimina los embeds de plugins. Nada legítimo lo ha necesitado en años.report-uries adonde van las violaciones, y es lo que hace posible todo el despliegue.
always importa del lado de nginx: sin él, la cabecera se omite en las respuestas de error, y un 404 o un 500 es exactamente cuando no quiere que le enmarquen.
Una trampa de nginx que le costará una tarde: add_header se hereda de un nivel exterior solo cuando el nivel interior no declara ningún add_header propio. Un solo add_header dentro de su bloque server, o de cualquier bloque location dentro de él, descarta en silencio todas las cabeceras definidas más arriba. Sin error, sin aviso: sencillamente dejan de llegar.
Paso 3: construir el endpoint de informes antes de enviar nada
Una ruta, con límite de tasa:
Route::post('/csp-report', [CspReportController::class, 'store'])
->middleware('throttle:csp-report');
Tiene que estar exenta de CSRF, porque el navegador la envía sin autenticar, sin sesión y sin token:
$middleware->validateCsrfTokens(except: ['csp-report']);
Lo que la convierte en un endpoint de escritura público y sin autenticar, así que trátelo como tal. El nuestro permite 30 por minuto por IP, acota cada cadena y escribe en su propio canal de log para que los informes no ahoguen el log de la aplicación:
'csp' => [
'driver' => 'daily',
'path' => storage_path('logs/csp.log'),
'permission' => 0640,
'level' => 'info',
'days' => env('LOG_CSP_DAYS', 14),
],
Dos formatos de carga, y necesita los dos. La especificación original envía {"csp-report": {...}} como application/csp-report. La Reporting API más nueva envía un array de {"type": "csp-violation", "body": {...}} como application/reports+json, con claves en camelCase dentro. Gestione solo uno y recogerá en silencio la mitad de sus informes:
if (isset($payload['csp-report']) && is_array($payload['csp-report'])) {
return [$payload['csp-report']];
}
if (array_is_list($payload)) {
return collect($payload)
->filter(fn ($report) => is_array($report) && is_array($report['body'] ?? null))
->map(fn ($report) => $report['body'])
->take(20)
->all();
}
Como las claves difieren entre los dos, lea ambas grafías al registrar:
'directive' => $this->str($violation['effective-directive'] ?? $violation['effectiveDirective'] ?? ''),
'blocked' => $this->str($violation['blocked-uri'] ?? $violation['blockedURL'] ?? ''),
Después filtre el ruido de las extensiones, o el log será inservible. Los gestores de contraseñas, los bloqueadores de anuncios y las extensiones de traducción inyectan scripts en sus páginas constantemente, y cada uno genera violaciones que no dicen nada sobre su política:
private const IGNORED_PREFIXES = [
'chrome-extension', 'moz-extension', 'safari-extension',
'safari-web-extension', 'webkit-masked-url',
];
Responda con 204. El navegador no quiere nada de vuelta.
Paso 4: enviarla en Report-Only, junto a una política aplicada segura
Content-Security-Policy-Report-Only tiene una sintaxis idéntica, no bloquea nada e informa de todo lo que habría bloqueado. Las dos cabeceras pueden enviarse a la vez y los navegadores las aplican de forma independiente.
Así que aplique algo obviamente seguro desde el primer día, y envíe la política estricta en Report-Only a su lado, las dos apuntando al mismo report-uri. Obtiene protección de inmediato y evidencia sobre la política que quiere de verdad.
Paso 5: usar toda la aplicación, y después leer el log
Los informes solo llegan de las páginas que alguien carga, así que esperar pasivamente prueba solo su página de inicio. Recórrala de forma deliberada: las páginas públicas, cada formulario, el admin, las partes del admin que rara vez abre.
Cada violación cae en uno de tres grupos, y el del medio es el interesante:
- Algo que puede arreglar en el origen. El mejor resultado: la política se mantiene estricta.
- Algo que no sabía que su aplicación hacía. Aquí está el valor, y es la razón para hacer bien la fase de Report-Only. La nuestra sacó a la luz un valor por defecto del framework que enviaba en silencio datos de usuario a un tercero en cada carga de página del admin; invisible en el código, evidente en los informes. Lo que dos días de informes detectaron es la versión larga.
- Algo que una dependencia requiere y no puede cambiar. Concédalo, y escriba por qué.
Paso 6: promoverla, y seguir informando
Renombre la cabecera, recargue, listo. Después deje el report-uri en su sitio: las políticas aplicadas siguen informando, con "disposition": "enforce", así que una política que rompa dentro de seis meses se anuncia en un log y no como una pantalla en blanco que ningún visitante menciona.
Escriba la reversión donde vive la política, porque la querrá bajo presión y no de memoria. La nuestra dice: renombrar la cabecera de vuelta a Content-Security-Policy-Report-Only y systemctl reload nginx.
Paso 7: lo que queda, y lo que vale
Nuestra política lleva 'unsafe-inline' y 'unsafe-eval'. Las dos son debilidades reales y llamar «terminada» a la cabecera sería venderla de más.
La mejora que importa es quitar 'unsafe-inline' para los scripts, lo que significa nonces, lo que significa generar la cabecera en PHP: la otra rama del paso 1. 'unsafe-eval' tendría que quedarse de todos modos si ejecuta Livewire, porque Alpine evalúa las expresiones de sus atributos en tiempo de ejecución. Todavía no lo hemos hecho en este sitio; es un cambio mayor que todo lo anterior junto.
Incluso con las dos, la política merece la pena. Un script inyectado no puede cargar código de otro origen ni enviar nada a uno, que es la mayor parte de aquello para lo que sirve un script inyectado.
Verificar que funciona
Compruebe que la cabecera llega, en una página y en un 404:
curl -sI https://example.com/ | grep -i content-security
curl -sI https://example.com/does-not-exist | grep -i content-security
Después demuestre la ruta de informes de extremo a extremo, porque una política que no informa a ningún sitio falla en silencio:
curl -sS -X POST https://example.com/csp-report \
-H 'Content-Type: application/csp-report' \
-d '{"csp-report":{"effective-directive":"img-src","blocked-uri":"https://example.net/x.png"}}' \
-w '%{http_code}\n'
Eso debería devolver 204 y dejar una línea en storage/logs/csp-*.log. Si el log sigue vacío, arréglelo antes de fiarse de nada que el navegador no le cuente.
Por último, abra el sitio con la consola visible y recórralo. Los recursos bloqueados se reportan ahí en lenguaje claro, y es más rápido que leer el log mientras todavía está dando forma a la política.
Hacemos este tipo de trabajo en aplicaciones Laravel y en aplicaciones web heredadas. Si quiere que alguien ponga una política en la suya y la observe antes de aplicarla, cuéntenos qué está ejecutando.
Preguntas frecuentes
¿La cabecera CSP debería ponerse en nginx o en un middleware de Laravel?
En nginx si la política es la misma en todas las respuestas, porque entonces se aplica también a los archivos estáticos y a las páginas de error, sin que intervenga PHP. En un middleware de Laravel si quiere nonces por petición, porque el valor tiene que cambiar en cada respuesta y estar disponible para las vistas que renderizan los bloques inline. Las dos son legítimas; empiece por la más sencilla.
¿Cuánto tiempo debería dejar una política en Report-Only?
El suficiente para haber usado todo, que suelen ser unos días y no unas semanas. Los informes solo le hablan de las páginas que alguien visitó, así que un área de admin que nadie abrió durante la prueba está sin probar. Recorra toda la aplicación de forma deliberada en lugar de esperar pasivamente.
¿Qué pasa con los informes una vez que la política se aplica?
Siguen funcionando. Las políticas aplicadas siguen enviando informes, con una disposition de enforce, así que cualquier cosa que rompa más adelante se anuncia en un log en lugar de como una página en blanco que ningún visitante le contará. Conserve el report-uri después de promoverla.