Una Content Security Policy estricta en un sitio Laravel, y las dos cosas que detectó

Una Content Security Policy estricta en un sitio Laravel, y las dos cosas que detectó

Este sitio se publicó sin ninguna cabecera de seguridad. Añadir la mayoría llevó unos cinco minutos: impedir que los navegadores reinterpreten los tipos de contenido, negarse a ser enmarcado, recortar lo que sale en el Referer, desactivar las APIs de dispositivo que aquí no usa nada.

La Content Security Policy no fueron cinco minutos. Es la única de estas cabeceras que puede romper su aplicación, y la única en la que la parte interesante es lo que le cuenta sobre código que creía conocer.

Las cabeceras baratas, y la trampa de nginx debajo de ellas

Cuatro cabeceras, sin inconvenientes, puestas una vez en el contexto http para que no haya nada que editar en un vhost gestionado por certbot:

add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=(), usb=()" always;

always importa: sin él, estas se omiten en las respuestas de error, y un 404 o un 500 es exactamente cuando no quiere sniffing ni enmarcado.

La política de referrer es la que la gente pone y luego lamenta. La predeterminada envía la URL completa a cualquier sitio al que enlace. Nuestras URLs de admin contienen IDs de consultas. strict-origin-when-cross-origin envía la URL completa a nosotros mismos y solo el origen pelado a cualquier otro.

Debajo de todas estas hay una trampa de nginx: add_header se hereda solo cuando el nivel interior no declara ninguno propio, así que un add_header suelto en un bloque server descarta el lote en silencio. Nos costó una tarde en otro archivo de configuración.

La CSP es distinta porque puede romper cosas

La política que ejecutamos ahora es una línea:

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 parte que hace el trabajo. Un <script src="//evil.com/x.js"> inyectado no se carga. Tampoco una imagen, una fuente o un fetch() inyectados apuntando al servidor de otro, que es como suelen salir los datos robados: la carga tiene que llegar a casa de alguna forma, y cada camino a casa es una petición de red a otro origen.

La pega es que una política así de estricta también bloqueará cosas que hace su propia aplicación, y se entera cuando una página se queda en blanco en producción. Así que no se entere de esa forma.

Publíquela en dos mitades

La CSP tiene una segunda cabecera, Content-Security-Policy-Report-Only. Misma sintaxis, no bloquea nada, y el navegador le envía un informe JSON describiendo cualquier cosa que habría bloqueado. Las dos cabeceras pueden enviarse a la vez, y los navegadores las aplican de forma independiente.

Eso le da un despliegue gradual en lugar de una apuesta. Enviamos una política aplicada lo bastante laxa como para ser obviamente segura, y la estricta a su lado en Report-Only, las dos apuntando al mismo report-uri. Después usamos el sitio con normalidad durante dos días —las páginas públicas, el admin, los formularios— y leímos lo que llegó.

Conserve el report-uri después de promover la política. Las políticas aplicadas siguen informando, con "disposition": "enforce", así que cualquier cosa que rompa más adelante se anuncia en un archivo de log en lugar de como una pantalla en blanco silenciosa. Y escriba cómo echarse atrás: renombrar la cabecera a Content-Security-Policy-Report-Only y recargar nginx lleva unos diez segundos, que es el tipo de cosa que quiere escrita en el archivo, no recordada a las once de la noche.

Recoger los informes

El endpoint tiene unas cuarenta líneas, y hay tres cosas en él fáciles de hacer mal: los navegadores envían dos formatos JSON distintos, tiene que estar exento de CSRF porque el navegador lo envía sin sesión y sin token, y el ruido de las extensiones del navegador inundará el log si no lo filtra. La versión paso a paso tiene el código.

Lo que importa aquí es que existía antes de enviar la política. Un report-uri que apunta a nada falla en silencio, y se entera concluyendo que su aplicación está limpia.

Lo que encontraron dos días de informes

Dos cosas, y ninguna era visible leyendo el código.

Siete violaciones de img-src, todas bloqueando https://ui-avatars.com. El proveedor de avatares por defecto de Filament construye una URL como https://ui-avatars.com/api/?name=D+S&..., así que cada carga del panel enviaba las iniciales del usuario con sesión iniciada —junto con la dirección IP, el user agent y la URL de referencia que lleva cualquier petición HTTP— a un tercero, desde una página autenticada. Era la única petición externa que hacía todo el sitio. Nadie la había elegido. Es el valor por defecto del framework y llegó con el widget de la cuenta.

Lo arreglamos en el origen en lugar de permitir el dominio. Un proveedor de avatares local dibuja las iniciales como un SVG y lo devuelve como una URI data:, que img-src 'self' data: ya permite, así que la política no cambió y el sitio no ganó ninguna dependencia que pueda romperse o venderse:

return 'data:image/svg+xml;base64,'.base64_encode($svg);

Treinta y cuatro violaciones de script-src bloqueando eval, todas desde una página de admin y ninguna desde una pública. Eso es Alpine, que viene dentro del bundle de Livewire y evalúa las expresiones de los atributos x-* en tiempo de ejecución. No hay forma de arreglarlo sin forkear la capa de plantillas de Filament, así que 'unsafe-eval' está en la política.

Lo que concedimos, y lo que cuesta

La política lleva 'unsafe-inline' para scripts y estilos, y 'unsafe-eval' para scripts. Las dos son debilidades reales y es mejor decirlo que presentar la cabecera como un problema resuelto.

'unsafe-eval' se concede en todo el sitio, no solo bajo la ruta del admin. Consideramos acotarlo y decidimos que no: 'unsafe-inline' hace falta en todas partes de todos modos —el script del tema previo al pintado, los bloques JSON-LD, los estilos inline de Filament—, y quien pueda inyectar script inline no necesita eval para nada. Acotarlo habría comprado casi nada mientras ataba permanentemente la configuración de nginx a un valor que es configurable a propósito y que algún día cambiará alguien a quien no se le ocurrirá editar nginx.

Quitar 'unsafe-inline' es la mejora que importaría de verdad, y significa nonces por petición, lo que significa generar la cabecera en PHP y no en nginx. 'unsafe-eval' tendría que quedarse de todos modos. Es un cambio mucho mayor que este y no lo hemos hecho.

Lo que la política compra tal como está sigue siendo sustancial: un script inyectado no puede cargar código de otro origen, y no puede enviar nada a uno. Esa segunda mitad es precisamente la forma de la llamada a ui-avatars que detectaron los informes.

La única excepción de terceros

frame-src 'self' https://cal.com permite enmarcar ese host y nada más: sin ejecución de scripts, sin acceso a la red, sin acceso al DOM en ninguna dirección. Nuestra ventana de reserva enmarca una ruta que redirige al calendario, y como el frame navega dos veces, la CSP comprueba los dos saltos. De ahí 'self' junto al proveedor.

Cal.com también ofrece un script de embed, y lo rechazamos. Cargarlo significaría añadir script-src https://app.cal.com: JavaScript de terceros ejecutándose en nuestro propio origen, con la página a su disposición. Este sitio no carga ninguno, analítica incluida, y un widget de reservas no es la razón para empezar.

Si lo hace en su propia aplicación Laravel

Ponga hoy las cuatro cabeceras baratas; no hay nada que pensar. Después envíe la CSP estricta en Report-Only junto a ellas, apunte report-uri a un endpoint que haya escrito, y use su propio admin durante unos días antes de aplicar nada. Lea lo que llega, arregle lo que pueda en el origen, conceda lo que no pueda, y deje los informes activados después.

La parte que no esperábamos es que el resultado más útil no fue la aplicación de la política en absoluto. Fue una lista de cosas que nuestra propia aplicación hace y que habríamos jurado que no hacía.

Construimos y mantenemos aplicaciones Laravel, incluido el trabajo de software a medida y de aplicaciones web del que está hecho este sitio. Si quiere un segundo par de ojos sobre lo que carga su aplicación y adónde envía cosas, cuéntenos qué está ejecutando.

Preguntas frecuentes

¿Una Content Security Policy romperá mi aplicación Laravel?

Puede, y por eso se publica primero en Report-Only. En ese modo el navegador le envía un informe sobre cualquier cosa que la política habría bloqueado, pero no bloquea nada. Tras unos días de uso normal —incluida el área de admin, que suele ser donde están las sorpresas—, los informes le dicen lo que costaría de verdad aplicarla.

¿Puedo poner una CSP sin 'unsafe-inline'?

Solo si nada en sus páginas usa una etiqueta de script o de estilo inline, lo que en la práctica significa generar un nonce por petición en PHP y ponerlo en cada bloque inline. Eso es un proyecto de verdad. Una política con 'unsafe-inline' sigue mereciendo la pena: impide que un script inyectado cargue código de otro dominio e impide que se envíen datos a uno.

¿Dónde debería ponerse la cabecera, en Laravel o en nginx?

Cualquiera de los dos funciona. nginx es más sencillo si la política es estática, porque se aplica también a las páginas de error y a los archivos estáticos. Muévala a un middleware de PHP cuando quiera nonces por petición, porque solo la aplicación puede generarlos.

Trabaje con Emyrix

¿Está trabajando en algo parecido?

Construimos y mantenemos aplicaciones Laravel: portales de clientes, herramientas internas, capas de integración y bases de código heredadas que nadie quiere tocar. Si tiene un problema de rendimiento o de seguridad que prefiere no dejar pasar, cuéntenos qué ocurre y le diremos qué opinamos.