Latte es sinónimo de seguridad
Latte es el único sistema de plantillas de PHP con protección eficaz contra la crítica vulnerabilidad Cross-site Scripting (XSS). Y ello gracias al escapado sensible al contexto. Vamos a hablar de:
- el principio de la vulnerabilidad XSS y por qué es tan peligrosa
- qué hace a Latte tan eficaz defendiéndose del XSS
- con qué facilidad pueden aparecer agujeros de seguridad en plantillas de Twig, Blade y similares
Cross-site Scripting (XSS)
El Cross-site Scripting (XSS para abreviar) es una de las vulnerabilidades más habituales y peligrosas de los sitios web. Permite a un atacante inyectar un script malicioso (malware) en una página ajena, que después se ejecuta en el navegador de un usuario desprevenido.
¿Qué puede hacer un script así? Por ejemplo, enviar al atacante cualquier contenido de la página comprometida, incluidos los datos sensibles que se muestran tras iniciar sesión. Puede modificar la página o realizar otras peticiones en nombre del usuario. Si se tratara de un webmail, podría leer mensajes confidenciales, cambiar el contenido mostrado o modificar la configuración, por ejemplo activando el reenvío de copias de todos los mensajes a la dirección del atacante para acceder al correo futuro.
Por eso el XSS figura de forma constante entre las vulnerabilidades más peligrosas. Si aparece en un sitio web, hay que eliminarla lo antes posible para impedir que la exploten.
¿Cómo surge la vulnerabilidad?
El error se produce allí donde se genera la página web y se imprimen variables. Imagine que crea una página de búsqueda cuyo comienzo incluye un párrafo con el término buscado, así:
echo '<p>Search results for <em>' . $search . '</em></p>';
Un atacante puede escribir cualquier cadena en el cuadro de búsqueda y, por tanto, en la variable $search,
incluido código HTML como <script>alert("Hacked!")</script>. Como la salida no se sanea, pasa a formar
parte de la página mostrada:
<p>Search results for <em><script>alert("Hacked!")</script></em></p>
En lugar de mostrar la cadena buscada, el navegador ejecuta el JavaScript. Y así el atacante toma el control de la página.
Podría objetar que insertar código en una variable ejecuta JavaScript, pero solo en el navegador del atacante. ¿Cómo llega a la víctima? Desde ese punto de vista distinguimos varios tipos de XSS. En nuestro ejemplo de búsqueda hablamos de XSS reflejado. Aquí hay que engañar a la víctima para que pulse un enlace que contenga el código malicioso en un parámetro:
https://example.com/?search=<script>alert("Hacked!")</script>
Llevar al usuario a pulsar el enlace requiere algo de ingeniería social, pero no es demasiado complicado. Los usuarios pulsan
enlaces, ya sea en correos o en redes sociales, sin pensárselo mucho. Y que la dirección contenga algo sospechoso se puede
disimular con un acortador de URL; el usuario solo ve entonces bit.ly/xxx.
Existe, sin embargo, una segunda forma de ataque mucho más peligrosa, conocida como XSS almacenado o XSS persistente, en la que el atacante consigue guardar el código malicioso en el servidor, de modo que se inserta automáticamente en determinadas páginas.
Un ejemplo son las páginas donde los usuarios escriben comentarios. El atacante envía una entrada que contiene código y esta se guarda en el servidor. Si las páginas no están suficientemente protegidas, el código se ejecutará después en el navegador de cada visitante.
Podría parecer que el meollo del ataque está en colar la cadena <script> en la página. En realidad, hay muchas maneras de insertar
JavaScript. Veamos un ejemplo con un atributo HTML. Imagine una galería de fotos donde se pueden añadir descripciones a las
imágenes, que se muestran en el atributo alt:
echo '<img src="' . $imageFile . '" alt="' . $imageAlt . '">';
Al atacante le basta con insertar como descripción una cadena hábilmente construida, " onload="alert('Hacked!'),
y si la salida no se sanea, el código resultante quedará así:
<img src="photo0145.webp" alt="" onload="alert('Hacked!')">
El atributo onload inyectado pasa a formar parte de la página. El navegador ejecuta el código que contiene en
cuanto se descarga la imagen. ¡Hackeado!
¿Cómo defenderse del XSS?
Cualquier intento de detectar ataques mediante listas negras, como bloquear la cadena <script>, es
insuficiente. La base de una defensa que funcione es el saneamiento sistemático de todos los datos impresos en la
página.
Esto consiste, ante todo, en sustituir todos los caracteres con significado especial por las secuencias correspondientes, lo
que se conoce coloquialmente como escapado (el primer carácter de la secuencia se llama carácter de escape, de ahí el
nombre). Por ejemplo, en el texto HTML el carácter < tiene un significado especial; si no queremos que se
interprete como el comienzo de una etiqueta, debemos sustituirlo por la secuencia visualmente equivalente, la entidad HTML
<. El navegador mostrará entonces el signo menor que.
Es crucial distinguir el contexto en el que se imprimen los datos. Y es que las cadenas se sanean de forma distinta según el contexto. En cada contexto hay caracteres distintos con significado especial. Por ejemplo, el escapado difiere en el texto HTML, en los atributos HTML, dentro de ciertos elementos especiales, etc. Hablaremos de ello en detalle enseguida.
El saneamiento se hace mejor justo en el momento de imprimir la cadena en la página, lo que garantiza que se hace realmente y exactamente una vez. Y lo ideal es que del saneamiento se encargue automáticamente el propio sistema de plantillas. Porque si no es automático, el programador puede olvidarlo. Y un solo descuido significa que el sitio web es vulnerable.
Ahora bien, el XSS no atañe solo a la impresión de datos en las plantillas, sino también a otras partes de la aplicación
que deben tratar correctamente los datos no confiables. Por ejemplo, el JavaScript de su aplicación debe usar
innerText o textContent con datos no confiables, no innerHTML. Hay que prestar especial
atención a las funciones que evalúan cadenas como JavaScript, como eval(), pero también setTimeout(),
o al uso de setAttribute() con atributos de evento como onload, etc. Todo esto, sin embargo, queda
fuera del alcance de las plantillas.
La defensa ideal, en 3 puntos:
- Reconoce el contexto en el que se imprimen los datos.
- Sanea los datos según las reglas de ese contexto (es decir, es “sensible al contexto”).
- Lo hace automáticamente.
Escapado sensible al contexto
¿Qué se entiende exactamente por contexto? Es un lugar dentro del documento con sus propias reglas para tratar los datos impresos. Depende del tipo de documento (HTML, XML, CSS, JavaScript, texto plano, …) y puede variar en partes concretas. Por ejemplo, en un documento HTML hay muchos lugares (contextos) donde rigen reglas muy distintas. Puede que le sorprenda cuántos son. Aquí tiene los cuatro primeros:
<p>#text</p>
<img src="#attribute">
<textarea>#rawtext</textarea>
<!-- #comment -->
El contexto básico y predeterminado de una página HTML es el texto HTML. ¿Qué reglas rigen aquí? Los caracteres
< y & tienen significado especial, pues representan el comienzo de una etiqueta o de una
entidad, así que debemos escaparlos sustituyéndolos por entidades HTML (< pasa a ser <,
& pasa a ser &).
El segundo contexto más habitual es el valor de un atributo HTML. Se diferencia del texto en que aquí tienen significado
especial las comillas " o ' que delimitan el atributo. Hay que escribirlas como entidad para que no se
interpreten como el final del atributo. En cambio, el carácter < se puede usar sin problema dentro de un
atributo, porque allí no tiene significado especial: no puede interpretarse como el comienzo de una etiqueta o de un comentario.
Pero cuidado: en HTML los valores de los atributos también se pueden escribir sin comillas, y entonces toda una serie de
caracteres pasa a tener significado especial, lo que lo convierte en otro contexto aparte.
Puede que le sorprenda, pero dentro de los elementos <textarea> y <title> rigen reglas
especiales, donde el carácter < no tiene que escaparse (aunque puede hacerse), salvo que le siga /.
Pero eso es más bien un detalle menor.
La cosa se pone interesante dentro de los comentarios HTML. Aquí no se usan entidades HTML para escapar. De hecho, ninguna especificación dice cómo debe hacerse el escapado en los comentarios. Basta con seguir unas reglas un tanto curiosas y evitar dentro ciertas combinaciones de caracteres.
Los contextos también pueden superponerse, algo que ocurre cuando incrustamos JavaScript o CSS en HTML. Esto se puede hacer de dos formas distintas, mediante un elemento o mediante un atributo:
<script>#js-element</script>
<img onclick="#js-attribute">
<style>#css-element</style>
<p style="#css-attribute"></p>
Dos caminos y dos formas distintas de escapar los datos. Dentro de los elementos <script> y
<style>, igual que en los comentarios HTML, no se escapa con entidades HTML. Al imprimir datos dentro de estos
elementos basta con respetar una regla: el texto no debe contener la secuencia </script o </style,
respectivamente.
En cambio, en los atributos style y on*** el escapado se hace con entidades HTML.
Y, naturalmente, dentro del JavaScript o el CSS anidados rigen las reglas de escapado de esos lenguajes. Así, una cadena en
un atributo como onload se escapa primero según las reglas de JS y después según las de los atributos HTML.
Uf… Como ve, el HTML es un documento muy complejo, con contextos superpuestos, y sin saber exactamente dónde está imprimiendo los datos (es decir, en qué contexto), no puede decir cómo hacerlo correctamente.
¿Quiere un ejemplo?
Tomemos la cadena Rock'n'Roll.
Si la imprime en texto HTML, en este caso concreto no hace falta ninguna sustitución, porque la cadena no contiene caracteres con significado especial. La situación cambia si la imprime dentro de un atributo HTML delimitado por comillas simples. Entonces hay que escapar las comillas convirtiéndolas en entidades HTML:
<div title='Rock'n'Roll'></div>
Eso ha sido sencillo. La situación se vuelve mucho más interesante cuando los contextos se superponen, por ejemplo si la cadena forma parte de JavaScript.
Primero, imprimámosla dentro del propio JavaScript. Es decir, la envolvemos en comillas y, a la vez, escapamos con el
carácter \ las comillas que contiene:
'Rock\'n\'Roll'
Podemos añadir también una llamada a una función para que el código haga algo:
alert('Rock\'n\'Roll');
Si insertamos este código en un documento HTML con <script>, no hace falta ninguna modificación más,
porque no contiene la secuencia prohibida </script:
<script> alert('Rock\'n\'Roll'); </script>
Pero si quisiéramos insertarlo en un atributo HTML, todavía tenemos que escapar las comillas como entidades HTML:
<div onclick='alert('Rock\'n\'Roll')'></div>
El contexto anidado no tiene por qué ser solo JS o CSS. También suele ser una URL. Los parámetros de las URL se escapan
convirtiendo los caracteres con significado especial en secuencias que empiezan por %. Ejemplo:
https://example.org/?a=Jazz&b=Rock%27n%27Roll
Y cuando imprimimos esta cadena en un atributo, aplicamos además el escapado propio de ese contexto y sustituimos
& por &:
<a href="https://example.org/?a=Jazz&b=Rock%27n%27Roll">
Si ha llegado hasta aquí, enhorabuena: ha sido exhaustivo. Ahora entiende bien qué son los contextos y el escapado. Y no tiene por qué preocuparse de lo complicado que resulta. Latte lo hace por usted automáticamente.
Latte frente a los sistemas ingenuos
Hemos mostrado cómo escapar correctamente en un documento HTML y lo crucial que es conocer el contexto, es decir, el lugar donde imprimimos los datos. Dicho de otro modo, cómo funciona el escapado sensible al contexto. Aunque es un requisito imprescindible para una defensa eficaz contra el XSS, Latte es el único sistema de plantillas de PHP que sabe hacerlo.
¿Cómo es posible, si hoy todos los sistemas afirman tener escapado automático? El escapado automático sin conocer el contexto es más bien una falacia que crea una falsa sensación de seguridad.
Sistemas de plantillas como Twig, Laravel Blade y otros no ven ninguna estructura HTML en la plantilla. Por tanto, tampoco ven los contextos. Comparados con Latte, son ciegos e ingenuos. Solo procesan sus propias etiquetas; todo lo demás es para ellos un flujo insignificante de caracteres:
░░░░░░░░░░░░░░░░░{{ foo }}░░░░░░░
░░░░░░░░░░░░░░░░{{ foo }}░░░░░░░░░
░░░░░░░░░░░░░░░░░░░░░░░░░░░░░{{ foo }}░░░░░░░░░
░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░{{ foo }}░░░░░░░░
░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░{{ foo }}░░░░░░
░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░{{ foo }}░░
░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░{{ foo }}░░░░░░░░░
░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░{{ foo }}░░░░░░░░░
░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░{{ foo }}░░░░░░░░░░░
░░░░░░░░░░░░░░░░░░░{{ foo }}░░░░
- in text: <span>{{ foo }}</span>
- in tag: <span {{ foo }} ></span>
- in attribute: <span title='{{ foo }}'></span>
- in unquoted attribute: <span title={{ foo }}></span>
- in attribute containing URL: <a href="{{ foo }}"></a>
- in attribute containing JavaScript: <img onload="{{ foo }}">
- in attribute containing CSS: <span style="{{ foo }}"></span>
- in JavaScript: <script>var = {{ foo }}</script>
- in CSS: <style>body { content: {{ foo }}; }</style>
- in comment: <!-- {{ foo }} -->
Los sistemas ingenuos se limitan a convertir mecánicamente los caracteres < > & ' " en entidades HTML,
un método de escapado válido en la mayoría de los casos, pero lejos de ser siempre suficiente. Por eso no pueden detectar ni
impedir la aparición de diversos agujeros de seguridad, como mostraremos a continuación.
Latte ve la plantilla igual que usted. Entiende HTML, XML, reconoce etiquetas, atributos, etc. Y gracias a ello distingue los distintos contextos y trata los datos en consecuencia. Ofrece así una protección realmente eficaz contra la crítica vulnerabilidad Cross-site Scripting.
░░░░░░░░░░░<span>{$foo}</span>
░░░░░░░░░░<span {$foo} ></span>
░░░░░░░░░░░░░░░░<span title='{$foo}'></span>
░░░░░░░░░░░░░░░░░░░░░░░░░<span title={$foo}></span>
░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░<a href="{$foo}"></a>
░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░<img onload="{$foo}">
░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░<span style="{$foo}"></span>
░░░░░░░░░░░░░░░░░░<script>░░░░░░{$foo}</script>
░░░░░░░░░░<style>░░░░░░░░░░░░░░░░{$foo}░░░</style>
░░░░░░░░░░░░░░<!--░{$foo}░-->
- in text: <span>{$foo}</span>
- in tag: <span {$foo} ></span>
- in attribute: <span title='{$foo}'></span>
- in unquoted attribute: <span title={$foo}></span>
- in attribute containing URL: <a href="{$foo}"></a>
- in attribute containing JavaScript: <img onload="{$foo}">
- in attribute containing CSS: <span style="{$foo}"></span>
- in JavaScript: <script>var = {$foo}</script>
- in CSS: <style>body { content: {$foo}; }</style>
- in comment: <!-- {$foo} -->
Demostración en vivo
A la izquierda ve la plantilla en Latte; a la derecha, el código HTML generado. La variable $text se imprime
varias veces, cada una en un contexto ligeramente distinto. Y, por tanto, escapada de forma ligeramente distinta. Puede editar
usted mismo el código de la plantilla, por ejemplo cambiando el contenido de la variable. Pruébelo:
¡Estupendo! Latte hace el escapado sensible al contexto automáticamente, de modo que el programador:
- no necesita pensar ni saber cómo escapar en cada sitio
- no puede equivocarse
- no puede olvidarse del escapado
Y estos no son ni siquiera todos los contextos que Latte distingue al imprimir y para los que adapta el tratamiento de los datos. Veamos ahora otros casos interesantes.
Cómo hackear los sistemas ingenuos
Con varios ejemplos prácticos mostraremos lo importante que es distinguir los contextos y por qué los sistemas de plantillas ingenuos no ofrecen protección suficiente contra el XSS, a diferencia de Latte. En los ejemplos usaremos Twig como representante de un sistema ingenuo, pero lo mismo vale para otros sistemas.
Vulnerabilidad en un atributo
Intentemos inyectar código malicioso en la página mediante un atributo HTML, como mostramos antes. Tengamos una plantilla en Twig que renderiza una imagen:
<img src={{ imageFile }} alt={{ imageAlt }}>
Fíjese en que no hay comillas alrededor de los valores de los atributos. El programador pudo olvidarlas, algo que sencillamente pasa. Por ejemplo, en React el código se escribe así, sin comillas, y quien alterna entre lenguajes puede olvidarlas con facilidad.
El atacante inserta como descripción de la imagen una cadena hábilmente construida: foo onload=alert('Hacked!').
Ya sabemos que Twig no puede determinar si una variable se imprime en el flujo de texto HTML, dentro de un atributo, en un
comentario HTML, etc.; en resumen, no distingue contextos. Y se limita a convertir mecánicamente los caracteres
< > & ' " en entidades HTML. Así que el código resultante quedará así:
<img src=photo0145.webp alt=foo onload=alert('Hacked!')>
¡Se ha creado un agujero de seguridad!
Un atributo onload falsificado ha pasado a formar parte de la página y el navegador lo ejecuta en cuanto se
descarga la imagen.
Veamos ahora cómo trata Latte la misma plantilla:
<img src={$imageFile} alt={$imageAlt}>
Latte ve la plantilla igual que usted. A diferencia de Twig, entiende HTML y sabe que la variable se imprime como valor de un atributo que no está entre comillas. Por eso las añade. Cuando el atacante inserta la misma descripción, el código resultante queda así:
<img src="photo0145.webp" alt="foo onload=alert('Hacked!')">
Latte ha impedido con éxito el XSS.
Imprimir una variable en JavaScript
Gracias al escapado sensible al contexto, se pueden usar variables de PHP de forma nativa dentro de JavaScript.
<p onclick="alert({$movie})">{$movie}</p>
<script>var movie = {$movie};</script>
Si la variable $movie contiene la cadena 'Amarcord & 8 1/2', se generará la siguiente salida.
Fíjese en el escapado distinto que se usa dentro del HTML frente al de dentro de JavaScript, y en un tercer escapado distinto en
el atributo onclick:
<p onclick="alert("Amarcord & 8 1/2")">Amarcord & 8 1/2</p>
<script>var movie = "Amarcord & 8 1/2";</script>
Comprobación de enlaces
Latte comprueba automáticamente si una variable usada en atributos de URL como src, href,
action, formaction o data del elemento <object> contiene una URL segura.
Permite los protocolos habituales (http, https, ftp, mailto, tel,
sms) y las URL relativas, mientras que bloquea los potencialmente peligrosos, como javascript:.
{var $link = 'javascript:attack()'}
<a href={$link}>click here</a>
Imprime:
<a href="">click here</a>
La comprobación se puede desactivar con el filtro nocheck.
Frameworks del lado del cliente
Frameworks como Vue o Angular tratan una parte de la página como su propia plantilla e interpretan como expresión las llaves
dobles {{ ... }} que hay dentro. Si un atacante logra colar esa secuencia en una variable impresa, el framework la
evalúa en el navegador una vez cargada la página, aunque en el lado del servidor no se haya producido ningún XSS.
Latte lo impide. Al imprimir en texto HTML convierte el par {{ en {<!-- -->{; el comentario
vacío es invisible para el lector, pero impide que el framework reconozca su propia etiqueta. Cualquier otra aparición del
carácter { se sustituye por la entidad { (dentro de los valores de atributo, cada
{ se escapa así).
{var $query = 'Hi {{ constructor.constructor("alert(1)")() }}'}
<h1>Search: {$query}</h1>
Imprime:
<h1>Search: Hi {<!-- -->{ constructor.constructor("alert(1)")() }}</h1>
La protección funciona automáticamente y no requiere configuración.
Límites de Latte
Latte no es una protección completa contra el XSS para toda la aplicación. Nos disgustaría que dejara de pensar en la seguridad por usar Latte. El objetivo de Latte es garantizar que un atacante no pueda alterar la estructura de la página ni falsificar elementos o atributos HTML. Pero no comprueba que el contenido de los datos impresos sea correcto. Ni que el comportamiento de JavaScript lo sea. Eso queda fuera de la competencia de un sistema de plantillas. Verificar la corrección de los datos, sobre todo de los introducidos por el usuario y, por tanto, no confiables, es una tarea importante del programador.
¿Quiere poner a prueba sus conocimientos sobre el escapado en los distintos contextos? Pruebe el cuestionario sobre la vulnerabilidad XSS.