Juega de forma segura en IFrames de zona de pruebas

Crear una experiencia enriquecida en la Web actual casi inevitablemente implica incorporar componentes y contenido sobre los que no tienes un 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 usar cualquiera de ellos no es realmente una opción, pero ambos aumentan el riesgo de que algo malo™ pueda suceder en tu sitio. Cada widget que incorporas (cada anuncio, cada widget de redes sociales) es un posible vector de ataque para quienes tengan 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 secuencias de comandos y otro contenido de confianza específico. 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 CSP es binaria: el recurso se permite o no. En ocasiones, sería útil decir: “No sé si realmente confío en esta fuente de contenido, pero es muy atractiva. Insértalo, navegador, pero no dejes que dañe 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 es un problema. Si no requiere Flash, no debería haber ningún problema si desactivas la compatibilidad con complementos. Estamos lo más seguros posible si seguimos el principio de privilegio mínimo y bloqueamos cada función que no sea directamente relevante para la funcionalidad que queremos usar. El resultado es que ya no tenemos que confiar ciegamente en que un elemento de contenido incorporado no aprovechará 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 esa solución. 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 almacenaste 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 aún tiene varias opciones para un comportamiento molesto o malicioso: los videos, los complementos y las ventanas emergentes que se reproducen automáticamente son solo la punta del iceberg.

El atributo sandbox del elemento iframe nos brinda justo lo que necesitamos para endurecer las restricciones sobre el contenido enmarcado. Podemos indicarle al navegador que cargue el contenido de un marco específico en un entorno de privilegios bajos, lo que permite solo el subconjunto de capacidades necesarias para realizar cualquier tarea que se necesite.

Confía, pero verifica

El botón “Tweet” de Twitter es un excelente ejemplo de una funcionalidad que se puede incorporar de forma más segura en tu sitio a través de una zona de pruebas. Twitter te permite incrustar 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 cuidadosamente las funciones que requiere el botón. El código HTML que se carga en el marco ejecuta un fragmento de JavaScript desde los servidores de Twitter y genera una ventana emergente propagada con una interfaz de Twitter cuando se hace clic en ella. Esa interfaz necesita acceso a las cookies de Twitter para vincular el tweet a la cuenta correcta y 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 por ninguna otra funcionalidad. Como no los necesita, quitémoslos colocando el contenido del marco en la zona de pruebas.

La zona de pruebas funciona en función de una lista de entidades permitidas. Comenzamos por quitar todos los permisos posibles y, luego, volver a activar las capacidades individuales agregando marcas específicas a la configuración de la zona de pruebas. En el caso del 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 que requiere, y el navegador le negará de forma útil 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 funciones

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

Si se proporciona un iframe con un atributo de zona de pruebas vacío, el documento enmarcado estará completamente en la 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 contenido en 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 de origen coincidente. Los orígenes únicos no coinciden con ningún otro origen, 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 a ningún otro mecanismo de almacenamiento (almacenamiento de DOM, base de datos indexada, etc.).
  • El documento enmarcado no puede crear ventanas o 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 a sí mismo, no a su elemento superior de nivel superior. Si configuras window.top.location, se arrojará una excepción y hacer clic en el vínculo con target="_top" no tendrá ningún efecto.
  • Se bloquean las funciones que se activan automáticamente (elementos de formulario con enfoque automático, videos que se reproducen automáticamente, etcétera).
  • No se puede obtener el bloqueo del puntero.
  • El atributo seamless se ignora en el iframes que contiene el documento enmarcado.

Esto es muy estricto, y un documento cargado en un iframe completamente en zona de pruebas representa muy poco riesgo. Por supuesto, tampoco puede hacer mucho de valor: es posible que puedas usar una zona de pruebas completa para cierto contenido estático, pero la mayoría de las veces querrás relajar un poco las restricciones.

Con la excepción de los complementos, se puede anular cada una de estas restricciones si se agrega una marca al valor del atributo de zona de pruebas. Los documentos en zona de pruebas nunca pueden ejecutar complementos, ya que son código nativo sin zona de pruebas, pero todo lo demás está permitido:

  • allow-forms permite el envío de formularios.
  • allow-popups permite (¡sorpresa!) 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/ retendrá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é 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 algunos JavaScript para controlar la interacción del usuario.
  • Se requiere allow-popups, ya que el botón muestra un formulario de tweet en una ventana nueva.
  • allow-forms es obligatorio, ya que el formulario de tweet debe poder enviarse.
  • 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.

Un aspecto importante que se debe tener en cuenta es que las marcas de zona de pruebas aplicadas a un marco también se aplican a cualquier ventana o marco creado 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 en la que aparece el marco.

Con el atributo sandbox implementado, el widget obtiene solo los permisos que requiere, y las funciones como los complementos, la navegación superior y el bloqueo del puntero permanecen bloqueadas. Reducción del riesgo de incorporar el widget, sin efectos negativos Todos ganan.

Separación de privilegios

Es bastante obvio que es beneficioso usar una zona de pruebas para contenido de terceros para ejecutar su código no confiable en un entorno de privilegios bajos. Pero, ¿qué sucede con tu código? Confías en ti, ¿no? Entonces, ¿por qué preocuparse por la zona de pruebas?

Te haría la siguiente pregunta: Si tu código no necesita complementos, ¿por qué darle acceso a los complementos? En el mejor de los casos, es un privilegio que nunca usas, y en el peor, es un posible vector para que los atacantes ingresen. El código de todos tiene errores, y prácticamente todas las aplicaciones son vulnerables a la explotación de una forma u otra. Si usas la zona de pruebas para tu propio código, significa que, incluso si un atacante subverte con éxito tu aplicación, no tendrá acceso completo al origen de la aplicación; solo podrá hacer lo 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 tu aplicación en fragmentos lógicos y colocas cada fragmento en la zona de pruebas 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 de alto privilegio que tiene acceso al disco duro local y puede establecer conexiones de red, y muchos procesos de renderización de bajo privilegio que realizan el trabajo pesado de analizar contenido no confiable. Los renderizadores 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 forma de dañar un renderizador, no llegará muy lejos, ya que el renderizador no puede hacer mucho 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 causar daños, lo que reduce en gran medida el riesgo de que se lleve a cabo un ataque con éxito.

Cómo usar la zona de pruebas de eval() de forma segura

Con la zona de pruebas y la API de postMessage, el éxito de este modelo es bastante sencillo de aplicar a la Web. Algunos elementos de tu aplicación pueden residir en iframe en la zona de pruebas, y el documento principal puede administrar la comunicación entre ellos publicando mensajes y escuchando 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 con la validación de entradas y salidas. Veamos un ejemplo de juguete para ver cómo podría funcionar.

Evalbox es una aplicación interesante que toma una cadena y la evalúa como JavaScript. ¡Guau!, ¿no? Justo lo que estabas esperando todos estos años. Por supuesto, es una aplicación bastante peligrosa, ya que permitir que se ejecute JavaScript arbitrario significa que cualquier dato que un origen tenga para ofrecer está disponible. Mitigaremos el riesgo de que ocurran situaciones negativas asegurándonos de que el código se ejecute dentro de una zona de pruebas, lo que lo hace mucho más seguro. Analizaremos 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 conectando el 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. La usaremos para enviar el resultado de nuestro arduo trabajo cuando terminemos. Luego, haremos el trabajo pesado pasando los datos que se nos proporcionaron a eval(). Esta llamada se unió en un bloque try, ya que las operaciones prohibidas dentro de un iframe en zona de pruebas generarán excepciones de DOM con frecuencia. 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 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 en zona de pruebas, lo que solo permitirá 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 conectaremos todo para la ejecución. Primero, escucharemos las respuestas de iframe y las alert() a nuestros usuarios. Se supone que 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" &amp;&amp; e.source === frame.contentWindow)
        alert('Result: ' + e.data);
    });

A continuación, conectaremos un controlador de eventos a los clics en 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 de DOM. Del mismo modo, el código evaluado no puede cargar complementos, abrir ventanas nuevas ni realizar otras actividades molestas o maliciosas.

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 lo que escribimos anteriormente. La ventana superior de alto privilegio puede actuar como controlador y despachador, enviar mensajes a módulos específicos que tienen los menos privilegios posibles para realizar sus tareas, escuchar los resultados y garantizar que cada módulo esté bien alimentado con solo la información que requiere.

Sin embargo, ten en cuenta que debes tener mucho cuidado cuando trabajes con contenido enmarcado que provenga del mismo origen que el 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 hasta el elemento superior y quitar el atributo de zona de pruebas por completo.

Juega en tu zona de pruebas

La zona de pruebas está disponible en varios navegadores: Firefox 17 y versiones posteriores, IE 10 y versiones posteriores, y Chrome en el momento de escribir este artículo (por supuesto, caniuse tiene una tabla de compatibilidad actualizada). Aplicar el atributo sandbox al iframes que incluyes 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, el entorno de pruebas es una técnica eficaz para reducir el riesgo de que un atacante inteligente pueda aprovechar los errores de tu propio código. Cuando se separa una aplicación monolítica en un conjunto de servicios en zona de pruebas, cada uno responsable de una pequeña parte de la funcionalidad independiente, los atacantes se verán obligados a no solo vulnerar el contenido de marcos específicos, sino también su controlador. Esa es una tarea mucho más difícil, en especial, porque el controlador puede reducirse mucho en su alcance. Puedes dedicar tu esfuerzo relacionado con la seguridad a auditar ese código si le pides ayuda al navegador con el resto.

Eso no quiere decir que el entorno de pruebas es una solución completa al problema de seguridad en Internet. Ofrece una defensa en profundidad y, a menos que tengas control sobre los clientes de tus usuarios, aún no puedes confiar en la compatibilidad con navegadores para todos tus usuarios (si controlas los clientes de tus usuarios, por ejemplo, un entorno empresarial, ¡felicitaciones!). 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 puedas confiar únicamente. De todos modos, las capas son excelentes. Te sugiero que uses esta.

Lecturas adicionales

  • Privilege Separation in HTML5 Applications” es un artículo interesante que funciona a través del diseño de un pequeño framework y su aplicación a tres apps de HTML5 existentes.

  • La zona de pruebas puede ser aún más flexible cuando se combina con otros dos atributos de iframe nuevos: 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 hacia el contenido enmarcado. Por el momento, ambos tienen una compatibilidad bastante pobre con los navegadores (compilaciones nocturnas de Chrome y WebKit), pero será una combinación interesante en el futuro. Por ejemplo, puedes usar la zona de pruebas para 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>