Juega de forma segura en IFrames de zona de pruebas

Construir 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 impulsar la 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. A pesar de no ser una opción, no puedes usar cualquiera de ellas, pero ambas aumentan el riesgo de que se produzca Algo BadTM en tu sitio. Cada widget que incorpores (cada anuncio, cada widget de redes sociales) es un vector de ataque potencial para aquellos con intenciones maliciosas:

La Política de Seguridad del Contenido (CSP) puede mitigar los riesgos asociados con ambos tipos de contenido, ya que te brinda la capacidad de incluir en la lista blanca fuentes de secuencias de comandos y otro tipo de contenido que sean de confianza específica. Este es un paso importante en la dirección correcta, pero vale la pena señalar que la protección que ofrecen la mayoría de las directivas de la CSP es binaria: el recurso está permitido o no. Hay ocasiones en las que puede ser útil decir "No estoy seguro de que realmente confío en esta fuente de contenido, pero es taaan bella. Incorpóralo, navegador, pero no permitas que mi sitio deje de serlo".

Menor nivel de privilegios

En términos simples, buscamos un mecanismo que nos permita otorgar contenido que incorporemos solo el nivel mínimo de capacidad necesario para hacer su trabajo. Si un widget no necesita mostrar una ventana nueva, quitar el acceso a window.open no puede dañarse. Si no requiere Flash, desactivar la compatibilidad con complementos no debería ser un problema. Tenemos la seguridad más alta posible si seguimos el principio de privilegio mínimo y bloqueamos todas las funciones que no son directamente relevantes para la funcionalidad que nos gustaría usar. Como resultado, ya no tenemos que confiar ciegamente en que algún contenido incorporado no aprovechará los privilegios que no debería usar. En primer lugar, no tendrá acceso a la funcionalidad.

Los elementos iframe son el primer paso hacia un buen framework para esta solución. La carga de un componente que no es de confianza 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 cuenta con varias opciones para el comportamiento molesto o malicioso: el video de reproducción automática, los complementos y las ventanas emergentes son la punta del iceberg.

El atributo sandbox del elemento iframe nos proporciona 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 de privilegios bajos, lo que solo permite el subconjunto de capacidades necesarias para realizar cualquier tarea que necesite hacer.

Aprieta, pero verifica

El botón "Twittear" de Twitter es un excelente ejemplo de funcionalidad que se puede incorporar de manera más segura en tu sitio a través de una zona de pruebas. Twitter te permite incorporar el botón mediante 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 descubrir qué podemos bloquear, examinemos cuidadosamente qué funciones requiere el botón. El código HTML que se carga en el marco ejecuta un poco de JavaScript desde los servidores de Twitter y genera una ventana emergente propagada con una interfaz de tweet cuando se hace clic en él. 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 tuit. Eso es todo. El fotograma no necesita cargar ningún complemento, no necesita navegar por la ventana de nivel superior ni ninguna otra funcionalidad. Dado que no necesita esos privilegios, vamos a quitarlos de la zona de pruebas del contenido del marco.

Las zonas de pruebas funcionan a partir de una lista de entidades permitidas. Primero, quitamos 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, agregamos 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 otorgamos al marco todas las capacidades necesarias, y el navegador le denegará el acceso a cualquiera de los privilegios que no le otorgamos de forma explícita mediante el valor del atributo sandbox.

Control detallado de las capacidades

En el ejemplo anterior, vimos algunas de las posibles marcas de zona de pruebas. Ahora, exploremos el funcionamiento interno del atributo con más detalle.

En un iframe con un atributo de zona de pruebas vacío, el documento enmarcado quedará completamente como una zona de pruebas y se someterá a las siguientes restricciones:

  • No se ejecutará JavaScript en el documento enmarcado. Esto no solo incluye JavaScript cargado explícitamente 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 fallarán todas las verificaciones del mismo origen. Los orígenes únicos no coinciden nunca con otros orígenes, ni siquiera con ellos mismos. Entre otras consecuencias, esto significa que el documento no tiene acceso a los datos almacenados en las cookies de origen ni en ningún otro mecanismo de almacenamiento (almacenamiento del DOM, base de datos indexada, etcétera).
  • El documento enmarcado no puede crear ventanas ni diálogos nuevos (por ejemplo, a través de window.open o target="_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. La configuración de window.top.location arrojará una excepción, y hacer clic en un vínculo con target="_top" no tendrá ningún efecto.
  • Se bloquearán las funciones que se activan automáticamente (elementos de formulario enfocados automáticamente, videos con reproducción automática, etc.).
  • No se puede obtener el bloqueo del puntero.
  • Se ignora el atributo seamless en el elemento iframes que contiene el documento enmarcado.

Esta herramienta es muy draconiana, y un documento que se carga en un iframe con zona de pruebas completa implica muy pocos riesgos. Por supuesto, tampoco es útil: es posible que puedas aprovechar una zona de pruebas completa para el contenido estático, pero la mayoría de las veces querrás flexibilizar un poco las cosas.

Con la excepción de los complementos, se puede quitar cada una de estas restricciones agregando una marca al valor del atributo de la zona de pruebas. Los documentos de las zonas de pruebas nunca pueden ejecutar complementos, ya que estos son código nativo no perteneciente a la zona de pruebas. Sin embargo, todo lo demás es un juego justo:

  • allow-forms permite el envío de formularios.
  • allow-popups permite (¡choque!) ventanas emergentes.
  • allow-pointer-lock permite (¡sorpresa!) el bloqueo del puntero.
  • allow-same-origin permite que el documento mantenga su origen. Las páginas cargadas desde https://example.com/ conservarán el acceso a los datos de ese origen.
  • allow-scripts permite 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-navigation permite que el documento salga del marco navegando por la ventana de nivel superior.

Con esto en mente, podemos evaluar exactamente por qué obtuvimos el conjunto específico de marcas de zona de pruebas en el ejemplo anterior de Twitter:

  • Se requiere allow-scripts, ya que la página cargada en el marco ejecuta JavaScript para procesar la interacción del usuario.
  • allow-popups es obligatorio, ya que el botón muestra un formulario de tuit en una ventana nueva.
  • allow-forms es obligatorio, ya que se debe enviar el formulario de tuit.
  • allow-same-origin es 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.

Es importante tener en cuenta que las marcas de la zona de pruebas que se aplican a un marco también se aplican a cualquier ventana o marco creado en la zona de pruebas. Eso significa que debemos agregar allow-forms a la zona de pruebas del marco, aunque el formulario solo existe en la ventana emergente.

Con el atributo sandbox implementado, el widget obtiene solo los permisos que necesita, y las capacidades 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 un triunfo para todos los involucrados.

Separación de privilegios

La zona de pruebas de contenido de terceros para ejecutar su código que no es de confianza en un entorno de bajo privilegio es bastante beneficioso. Pero ¿qué pasa con tu propio código? Confía en ti, ¿verdad? ¿Por qué preocuparse por la zona de pruebas?

Yo cambiaría la 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 se usa y, en el peor, es un vector potencial para que los atacantes consigan un pie. El código de cada persona 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 implica que, incluso si un atacante anula tu aplicación con éxito, no obtendrá acceso completo al origen de la aplicación, sino que solo podrá realizar las acciones que podría realizar la aplicación. Sigue siendo mala, pero no tan malo como podría ser.

Puedes reducir aún más el riesgo si divides tu aplicación en partes lógicas y haces una zona de pruebas de cada parte con el privilegio mínimo posible. Esta técnica es muy común en el código nativo: Chrome, por ejemplo, se desglosa en un proceso de navegador de alto privilegio que tiene acceso al disco duro local y puede establecer conexiones de red, así como muchos procesos de renderizado con privilegios bajos que se encargan del trabajo pesado de analizar contenido que no es de confianza. Los procesadores no necesitan tocar el disco, ya que el navegador se encarga de proporcionarles toda la información que necesitan para renderizar una página. Incluso si un hacker inteligente encuentra una manera de corromper un procesador, no llegó muy lejos, ya que el procesador no puede hacer mucho interés por sí solo: todo el acceso de alto privilegio debe enrutarse a través del proceso del navegador. Los atacantes deberán encontrar varios agujeros en diferentes partes del sistema para hacer cualquier daño, lo que reduce en gran medida el riesgo de conseguir un dominio 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. Algunos fragmentos de tu aplicación pueden alojarse en objetos iframe de zona de pruebas, y el documento superior puede actuar como agente de comunicación entre ellos 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, para que sepas con exactitud dónde debes tener cuidado de validar la entrada y la salida. Veamos un ejemplo de un juguete para ver cómo podría funcionar.

Evalbox es una aplicación interesante que toma una cadena y la evalúa como JavaScript. ¡Vaya! Justo lo que estuviste esperando durante estos largos años. Es una aplicación bastante peligrosa, por supuesto, ya que permitir que se ejecute JavaScript arbitrario significa que todos y cada uno de los datos que ofrece un origen estarán disponibles. Para mitigar el riesgo de Bad ThingsTM, garantizaremos que el código se ejecute dentro de una zona de pruebas, lo que lo hace un poco más seguro. Trabajaremos en el código de adentro hacia afuera, empezando por 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 mediante un enlace 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 nuestro elemento superior desea que ejecutemos.

En el controlador, tomamos el atributo source del evento, que es la ventana superior. Usaremos esto para enviar el resultado del arduo trabajo una vez que hayamos terminado. Luego, pasaremos los datos que se nos proporcionaron a eval() para hacer el trabajo pesado. Esta llamada se concluyó en un bloque try, ya que las operaciones prohibidas dentro de un iframe de la zona de pruebas generarán con frecuencia excepciones de DOM. Las detectaremos y, en su lugar, informaremos un mensaje de error amigable. Por último, publicamos el resultado en la ventana superior. Esto es bastante sencillo.

El elemento superior es igualmente sencillo. Crearemos una pequeña IU con textarea para el código y 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 secuencias 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 prepararemos las cosas para su ejecución. Primero, escucharemos las respuestas de iframe y alert() para nuestros usuarios. Es probable que una aplicación real haga 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" &amp;&amp; e.source === frame.contentWindow)
        alert('Result: ' + e.data);
    });

A continuación, conectaremos un controlador de eventos para que haga clic en el button. Cuando el usuario haga clic, tomaremos el contenido actual de textarea y lo pasaremos al fotograma 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 evaluado no tenga acceso a información sensible, como las cookies o el almacenamiento del DOM. Del mismo modo, el código evaluado no puede cargar complementos, mostrar ventanas nuevas ni ninguna otra actividad molesta o maliciosa.

Puedes hacer lo mismo con tu propio código si divides las aplicaciones monolíticas en componentes de un solo propósito. Cada uno se puede incluir en una API de mensajería simple, como lo hemos escrito antes. La ventana superior con privilegios altos puede actuar como controlador y despachador, ya que envía mensajes a módulos específicos que tienen el menor nivel de privilegios posibles para realizar sus tareas, escucha los resultados y garantiza que cada módulo esté bien alimentado solo con la información que necesita.

Sin embargo, ten en cuenta que debes tener mucho cuidado cuando trabajas con contenido enmarcado que proviene 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 por completo el atributo de la zona de pruebas.

Juega en tu zona de pruebas

La zona de pruebas está disponible para ti en varios navegadores: Firefox 17 y versiones posteriores, IE10 y versiones posteriores, y Chrome en el momento en que se redacta este documento (caniuse, por supuesto, tiene una tabla de compatibilidad actualizada). Si aplicas el atributo sandbox al iframes que incluyas, podrás otorgar ciertos privilegios al contenido que muestran, solo aquellos 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 aprovecharse de los agujeros de tu propio código. Cuando 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 una funcionalidad independiente, los atacantes se verán obligados a comprometer no solo el contenido de marcos específicos, sino también su controlador. Esta es una tarea mucho más difícil, en especial porque el alcance del controlador puede reducirse de manera considerable. Puedes realizar el esfuerzo relacionado con la seguridad en auditar ese código si pides ayuda al navegador con el resto.

Eso no quiere decir que la zona de pruebas sea una solución completa al problema de 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 los usuarios (si controlas a tus clientes, un entorno empresarial, por ejemplo, ¡Muy bien!). Algún día... pero, por ahora, la zona de pruebas es otra capa de protección para fortalecer tus defensas, no es una defensa completa en la que solo puedes confiar. Aun así, las capas son excelentes. te sugiero que uses este.

Lecturas adicionales

  • "Separación de privilegios en aplicaciones HTML5" es un documento interesante en el que se trabaja a través del diseño de un marco de trabajo pequeño y su aplicación en tres aplicaciones HTML5 existentes.

  • La zona de pruebas puede ser aún más flexible cuando se combina con otros dos atributos nuevos de iframe: srcdoc y seamless. El primero te permite propagar un marco con contenido sin la sobrecarga de una solicitud HTTP, y el segundo permite que el estilo fluya al contenido enmarcado. Por el momento, la compatibilidad con los navegadores es bastante lamentable (es decir, las noches en Chrome y WebKit), pero será una combinación interesante en el futuro. Por ejemplo, puedes usar el siguiente código para agregar comentarios a un artículo en la zona de pruebas:

        <iframe sandbox seamless
                srcdoc="<p>This is a user's comment!
                           It can't execute script!
                           Hooray for safety!</p>"></iframe>