Crear una experiencia enriquecida en la Web actual casi inevitablemente implica incorporar componentes y contenido sobre los que no tienes control real. Los widgets de terceros pueden generar participación y desempeñar un papel fundamental en la experiencia general del usuario, y el contenido generado por usuarios a veces es incluso más importante que el contenido nativo de un sitio. Abstenerse de cualquiera de las dos opciones no es realmente una opción, pero ambas aumentan el riesgo de que ocurra algo malo en tu sitio. Cada widget que incorporas (cada anuncio, cada widget de redes sociales) es un vector de ataque potencial para quienes tienen intenciones maliciosas:
La Política de Seguridad del Contenido (CSP) puede mitigar los riesgos asociados con ambos tipos de contenido, ya que te permite incluir en la lista de entidades permitidas fuentes de confianza específicas de secuencias de comandos y otro contenido. Este es un gran paso en la dirección correcta, pero vale la pena señalar que la protección que ofrece la mayoría de las directivas de CSP es binaria: el recurso está permitido o no. A veces, sería útil decir "No estoy seguro de si realmente confío en esta fuente de contenido, pero es muy bonita. Incrústalo, por favor, navegador, pero no dejes que rompa mi sitio".
Privilegio mínimo
En esencia, buscamos un mecanismo que nos permita otorgar al contenido que incorporamos solo el nivel mínimo de capacidad necesario para realizar su trabajo. Si un widget no necesita abrir una ventana nueva, quitar el acceso a window.open no puede hacer daño. Si no requiere Flash, desactivar la compatibilidad con complementos no debería ser un problema. Somos lo más seguros posible si seguimos el principio de privilegio mínimo y bloqueamos todas las funciones que no sean directamente relevantes para la funcionalidad que queremos usar. Como resultado, ya no tenemos que confiar ciegamente en que algún contenido incorporado no aprovechará los privilegios que no debería usar. Simplemente, no tendrá acceso a la funcionalidad en primer lugar.
Los elementos iframe son el primer paso hacia un buen marco de trabajo para una solución de este tipo.
Cargar un componente no confiable en un iframe proporciona una medida de separación entre tu aplicación y el contenido que deseas cargar. El contenido enmarcado
no tendrá acceso al DOM de tu página ni a los datos que hayas almacenado de forma local, ni podrá
dibujar en posiciones arbitrarias de la página; su alcance se limita al
contorno del marco. Sin embargo, la separación no es realmente sólida. La página contenida
todavía tiene varias opciones para un comportamiento molesto o malicioso: la reproducción automática de videos,
los complementos y las ventanas emergentes son la punta del iceberg.
El atributo sandbox del elemento iframe nos brinda justo lo que necesitamos para ajustar las restricciones del contenido enmarcado. Podemos indicarle al navegador que cargue el contenido de un marco específico en un entorno con privilegios mínimos, lo que permite solo el subconjunto de capacidades necesarias para realizar cualquier trabajo que se deba hacer.
Confía, pero verifica
El botón "Tweet" de Twitter es un gran ejemplo de funcionalidad que se puede incorporar de forma más segura en tu sitio a través de una zona de pruebas. X te permite incorporar el botón a través de un iframe con el siguiente código:
<iframe src="https://platform.twitter.com/widgets/tweet_button.html"
style="border: 0; width:130px; height:20px;"></iframe>
Para determinar qué podemos bloquear, examinemos con atención qué capacidades requiere el botón. El HTML que se carga en el marco ejecuta un poco de JavaScript desde los servidores de Twitter y genera una ventana emergente con una interfaz de Twitter cuando se hace clic en ella. Esa interfaz necesita acceso a las cookies de Twitter para vincular el tweet con la cuenta correcta y necesita la capacidad de enviar el formulario de tweet. Eso es todo; el marco no necesita cargar ningún complemento, no necesita navegar por la ventana de nivel superior ni ninguna otra funcionalidad. Como no necesita esos privilegios, los quitaremos con una zona de pruebas para el contenido del marco.
La zona de pruebas funciona sobre la base de una lista de entidades permitidas. Comenzamos quitando todos los permisos posibles y, luego, volvemos a activar las capacidades individuales agregando marcas específicas a la configuración de la zona de pruebas. Para el widget de Twitter, decidimos habilitar JavaScript, las ventanas emergentes, el envío de formularios y las cookies de twitter.com. Para ello, podemos agregar un atributo sandbox a iframe con el
siguiente valor:
<iframe sandbox="allow-same-origin allow-scripts allow-popups allow-forms"
src="https://platform.twitter.com/widgets/tweet_button.html"
style="border: 0; width:130px; height:20px;"></iframe>
Eso es todo. Le dimos al marco todas las capacidades que requiere, y el navegador le denegará el acceso a cualquiera de los privilegios que no le otorgamos de forma explícita a través del valor del atributo sandbox.
Control detallado de las capacidades
En el ejemplo anterior, vimos algunas de las posibles marcas de zona de pruebas. Ahora, analicemos el funcionamiento interno del atributo con un poco más de detalle.
Si se proporciona un iframe con un atributo de zona de pruebas vacío, el documento enmarcado se colocará por completo en una zona de pruebas, lo que lo someterá a las siguientes restricciones:
- JavaScript no se ejecutará en el documento enmarcado. Esto no solo incluye JavaScript cargado de forma explícita a través de etiquetas de secuencia de comandos, sino también controladores de eventos intercalados y URLs de javascript:. Esto también significa que se mostrará el contenido incluido en las etiquetas noscript, exactamente como si el usuario hubiera inhabilitado la secuencia de comandos.
- El documento enmarcado se carga en un origen único, lo que significa que todas las verificaciones del mismo origen fallarán. Los orígenes únicos nunca coinciden con otros orígenes, ni siquiera con ellos mismos. Entre otros impactos, esto significa que el documento no tiene acceso a los datos almacenados en las cookies de ningún origen ni en ningún otro mecanismo de almacenamiento (almacenamiento del DOM, IndexedDB, etcétera).
- El documento enmarcado no puede crear ventanas ni diálogos nuevos (por ejemplo, a través de
window.openotarget="_blank"). - No se pueden enviar formularios.
- No se cargarán los complementos.
- El documento enmarcado solo puede navegar por sí mismo, no por su elemento superior de nivel superior.
Si configuras
window.top.location, se arrojará una excepción, y hacer clic en el vínculo contarget="_top"no tendrá ningún efecto. - Se bloquean las funciones que se activan automáticamente (elementos de formulario con enfoque automático, videos de reproducción automática, etc.).
- No se puede obtener el bloqueo del puntero.
- El atributo
seamlessse ignora eniframesque contiene el documento enmarcado.
Esto es bastante draconiano, y un documento cargado en un iframe completamente aislado
supone un riesgo muy bajo. Por supuesto, tampoco puede hacer mucho de valor: es
posible que puedas salirte con la tuya con una zona de pruebas completa para algún contenido estático, pero la mayoría
de las veces querrás flexibilizar un poco las cosas.
Con la excepción de los complementos, cada una de estas restricciones se puede levantar agregando una marca al valor del atributo de la zona de pruebas. Los documentos de zona de pruebas nunca pueden ejecutar complementos, ya que estos son código nativo sin zona de pruebas, pero todo lo demás es válido:
allow-formspermite el envío de formularios.allow-popupspermite (¡sorpresa!) las ventanas emergentes.allow-pointer-lockpermite (¡sorpresa!) el bloqueo del puntero.allow-same-originpermite que el documento mantenga su origen; las cargas de página desdehttps://example.com/conservarán el acceso a los datos de ese origen.allow-scriptspermite la ejecución de JavaScript y también permite que las funciones se activen automáticamente (ya que sería trivial implementarlas a través de JavaScript).allow-top-navigationpermite que el documento salga del marco navegando por la ventana de nivel superior.
Con esto en mente, podemos evaluar exactamente por qué terminamos con el conjunto específico de marcas de zona de pruebas en el ejemplo de Twitter anterior:
- Se requiere
allow-scripts, ya que la página cargada en el marco ejecuta código JavaScript para controlar la interacción del usuario. - Se requiere
allow-popups, ya que el botón abre un formulario de Twitter en una ventana nueva. allow-formses obligatorio, ya que el formulario de publicación de tweets debe poder enviarse.allow-same-origines necesario, ya que, de lo contrario, no se podría acceder a las cookies de twitter.com y el usuario no podría acceder para publicar el formulario.
Un aspecto importante que se debe tener en cuenta es que las marcas de zona de pruebas que se aplican a un marco también
se aplican a las ventanas o los marcos que se crean en la zona de pruebas. Esto significa que debemos
agregar allow-forms a la zona de pruebas del marco, aunque el formulario solo exista
en la ventana que abre el marco.
Con el atributo sandbox implementado, el widget solo obtiene los permisos que
necesita, y las funciones como los complementos, la navegación superior y el bloqueo del puntero permanecen
bloqueadas. Redujimos el riesgo de incorporar el widget, sin efectos negativos.
Es una victoria para todos los involucrados.
Separación de privilegios
La zona de pruebas del contenido de terceros para ejecutar su código no confiable en un entorno con pocos privilegios es bastante beneficiosa. ¿Pero qué pasa con tu propio código? Confías en ti, ¿verdad? Entonces, ¿por qué preocuparse por la zona de pruebas?
Yo daría vuelta esa pregunta: si tu código no necesita complementos, ¿por qué darle acceso a ellos? En el mejor de los casos, es un privilegio que nunca usas; en el peor, es un vector potencial para que los atacantes se infiltren. El código de todos tiene errores, y prácticamente todas las aplicaciones son vulnerables a la explotación de una forma u otra. La zona de pruebas de tu propio código significa que, incluso si un atacante logra subvertir tu aplicación, no se le otorgará acceso completo al origen de la aplicación; solo podrá hacer cosas que la aplicación podría hacer. Sigue siendo malo, pero no tanto como podría ser.
Puedes reducir aún más el riesgo si divides la aplicación en partes lógicas y creas una zona de pruebas para cada parte con el privilegio mínimo posible. Esta técnica es muy común en el código nativo: Chrome, por ejemplo, se divide en un proceso de navegador con privilegios elevados que tiene acceso al disco duro local y puede establecer conexiones de red, y muchos procesos de renderizador con privilegios bajos que realizan el trabajo pesado de analizar contenido no confiable. Los procesadores no necesitan tocar el disco, el navegador se encarga de darles toda la información que necesitan para renderizar una página. Incluso si un hacker inteligente encuentra una forma de corromper un renderizador, no llegará muy lejos, ya que el renderizador no puede hacer mucho por sí solo: todo el acceso con privilegios elevados debe enrutarse a través del proceso del navegador. Los atacantes deberán encontrar varios agujeros en diferentes partes del sistema para causar algún daño, lo que reduce enormemente el riesgo de un pwnage exitoso.
Zona de pruebas segura de eval()
Con la zona de pruebas y la API de postMessage, el éxito de este modelo es bastante sencillo de aplicar a la Web. Las partes de
tu aplicación pueden residir en iframes de zona de pruebas, y el documento superior puede
mediar la comunicación entre ellas publicando mensajes y escuchando
las respuestas. Este tipo de estructura garantiza que los exploits en cualquier parte de la app causen el menor daño posible. También tiene la ventaja de obligarte a
crear puntos de integración claros, de modo que sepas exactamente dónde debes tener
cuidado al validar la entrada y la salida. Veamos un ejemplo sencillo
para ver cómo podría funcionar.
Evalbox es una aplicación interesante que toma una cadena y la evalúa como JavaScript. Increíble, ¿verdad? Justo lo que has estado esperando todos estos años. Por supuesto, es una aplicación bastante peligrosa, ya que permitir que se ejecute JavaScript arbitrario significa que todos los datos que un origen tiene para ofrecer están disponibles. Mitigaremos el riesgo de que ocurran cosas malas asegurándonos de que el código se ejecute dentro de una zona de pruebas, lo que lo hace mucho más seguro. Trabajaremos con el código de adentro hacia afuera, comenzando con el contenido del marco:
<!-- frame.html -->
<!DOCTYPE html>
<html>
<head>
<title>Evalbox's Frame</title>
<script>
window.addEventListener('message', function (e) {
var mainWindow = e.source;
var result = '';
try {
result = eval(e.data);
} catch (e) {
result = 'eval() threw an exception.';
}
mainWindow.postMessage(result, event.origin);
});
</script>
</head>
</html>
Dentro del marco, tenemos un documento mínimo que simplemente escucha los mensajes de su elemento superior conectándose al evento message del objeto window.
Cada vez que el elemento superior ejecute postMessage en el contenido del iframe, se activará este evento, lo que nos dará acceso a la cadena que el elemento superior desea que ejecutemos.
En el controlador, tomamos el atributo source del evento, que es la ventana
superior. La usaremos para enviar el resultado de nuestro arduo trabajo una vez que hayamos
terminado. Luego, haremos el trabajo pesado pasando los datos que recibimos a
eval(). Esta llamada se unió en un bloque try, ya que las operaciones prohibidas
dentro de un iframe de zona de pruebas suelen generar excepciones de DOM. Las detectaremos
y, en su lugar, informaremos un mensaje de error descriptivo. Por último, publicamos el resultado
en la ventana superior. Esto es bastante sencillo.
El elemento superior es igual de sencillo. Crearemos una IU pequeña con un textarea
para el código y un button para la ejecución, y extraeremos frame.html a través de un
iframe de zona de pruebas, lo que permitirá solo la ejecución de la secuencia de comandos:
<textarea id='code'></textarea>
<button id='safe'>eval() in a sandboxed frame.</button>
<iframe sandbox='allow-scripts'
id='sandboxed'
src='frame.html'></iframe>
Ahora, conectaremos todo para la ejecución. Primero, escucharemos las respuestas de
iframe y las alert() a nuestros usuarios. Presumiblemente, una aplicación real
haría algo menos molesto:
window.addEventListener('message',
function (e) {
// Sandboxed iframes which lack the 'allow-same-origin'
// header have "null" rather than a valid origin. This means you still
// have to be careful about accepting data via the messaging API you
// create. Check that source, and validate those inputs!
var frame = document.getElementById('sandboxed');
if (e.origin === "null" && e.source === frame.contentWindow)
alert('Result: ' + e.data);
});
A continuación, conectaremos un controlador de eventos a los clics en el button. Cuando el usuario
haga clic, tomaremos el contenido actual de textarea y lo pasaremos al
marco para su ejecución:
function evaluate() {
var frame = document.getElementById('sandboxed');
var code = document.getElementById('code').value;
// Note that we're sending the message to "*", rather than some specific
// origin. Sandboxed iframes which lack the 'allow-same-origin' header
// don't have an origin which you can target: you'll have to send to any
// origin, which might alow some esoteric attacks. Validate your output!
frame.contentWindow.postMessage(code, '*');
}
document.getElementById('safe').addEventListener('click', evaluate);
Fácil, ¿verdad? Creamos una API de evaluación muy simple y podemos asegurarnos de que el código que se evalúa no tenga acceso a información sensible, como cookies o almacenamiento del DOM. Del mismo modo, el código evaluado no puede cargar complementos, abrir ventanas nuevas ni realizar ninguna otra actividad molesta o maliciosa.
Puedes hacer lo mismo con tu propio código dividiendo las aplicaciones monolíticas en componentes de un solo propósito. Cada uno se puede unir en una API de mensajería simple, como la que escribimos anteriormente. La ventana superior con privilegios elevados puede actuar como controlador y despachador, enviando mensajes a módulos específicos que tienen la menor cantidad de privilegios posible para realizar sus tareas, escuchando los resultados y garantizando que cada módulo reciba solo la información que necesita.
Sin embargo, ten en cuenta que debes tener mucho cuidado cuando trabajes con contenido enmarcado que provenga del mismo origen que el elemento superior. Si una página en
https://example.com/ enmarca otra página en el mismo origen con una zona de pruebas
que incluye las marcas allow-same-origin y allow-scripts,
la página enmarcada puede llegar al elemento superior y quitar el atributo de zona de pruebas
por completo.
Juega en tu zona de pruebas
La zona de pruebas está disponible para ti en una variedad de navegadores: Firefox 17+,
IE10+ y Chrome en el momento de escribir este artículo (caniuse, por supuesto, tiene una tabla de compatibilidad
actualizada). Aplicar el atributo sandbox a los iframes que incluyas te permite otorgar ciertos privilegios al contenido que muestran, solo aquellos privilegios que son necesarios para que el contenido funcione correctamente. Esto te brinda la oportunidad de reducir el riesgo
asociado con la inclusión de contenido de terceros, más allá de lo que
ya es posible con la Política de seguridad del
contenido.
Además, la zona de pruebas es una técnica potente para reducir el riesgo de que un atacante inteligente pueda aprovechar los agujeros de tu propio código. Si se separa una aplicación monolítica en un conjunto de servicios de zona de pruebas, cada uno responsable de una pequeña parte de la funcionalidad independiente, los atacantes se verán obligados no solo a vulnerar el contenido de marcos específicos, sino también su controlador. Esa es una tarea mucho más difícil, sobre todo porque el controlador puede reducirse en gran medida en el alcance. Puedes dedicar tu esfuerzo relacionado con la seguridad a auditar ese código si le pides ayuda al navegador con el resto.
Esto no significa que la zona de pruebas sea una solución completa para el problema de la seguridad en Internet. Ofrece defensa en profundidad y, a menos que tengas control sobre los clientes de tus usuarios, aún no puedes confiar en la compatibilidad del navegador para todos tus usuarios (si controlas los clientes de tus usuarios, por ejemplo, un entorno empresarial, ¡hurra!). Algún día… pero, por ahora, la zona de pruebas es otra capa de protección para reforzar tus defensas, no es una defensa completa en la que puedas confiar únicamente. Aun así, las capas son excelentes. Te sugiero que uses este.
Lecturas adicionales
"Privilege Separation in HTML5 Applications" es un artículo interesante que analiza el diseño de un framework pequeño y su aplicación a tres apps HTML5 existentes.
La zona de pruebas puede ser aún más flexible cuando se combina con otros dos atributos nuevos de iframe:
srcdocyseamless. El primero te permite propagar un marco con contenido sin la sobrecarga de una solicitud HTTP, y el segundo permite que el estilo fluya hacia el contenido enmarcado. Por el momento, ambos tienen una compatibilidad bastante deficiente con los navegadores (Chrome y WebKit nightlies), pero serán una combinación interesante en el futuro. Por ejemplo, podrías restringir los comentarios de un artículo con el siguiente código:<iframe sandbox seamless srcdoc="<p>This is a user's comment! It can't execute script! Hooray for safety!</p>"></iframe>