background blurbackground mobile blur

1/1/1970

Cómo hice que Foony funcionara detrás de proxies

¡Muy buenas! Sé desde hace mucho que los proxies web pueden causar problemas de compatibilidad en las webs. Pero el soporte de Foony detrás de proxies ha sido históricamente terrible, y arreglar la compatibilidad de Foony con proxies ha sido bastante complicado.

Y no es un problema de “Foony usa APIs exóticas” (aunque sí que lo hacemos). Ha sido una combinación de:

  • Proxies que hacen sustituciones de texto muy agresivas en sitios en los que no deberían tocarlos.
  • Proxies que tratan el dominio principal del sitio de forma distinta a “otros” dominios (CDNs, hosts de assets, etc.).
  • Y la cruda realidad de que algunos proxies simplemente no pueden con aplicaciones web modernas (correcta gestión de HTTPS, WebSockets, etc.).

No funcionamos con todos los proxies, pero ahora al menos funcionamos con croxyproxy y proxyorb, que era el objetivo.

Abajo explico qué se rompía, por qué se rompía y cuáles fueron los arreglos que de verdad importaron.


Primera pasada: shaders de Three.js válidos pero rotos

El síntoma

Cuando probé croxyproxy, no podía jugar a 8 Ball Pool ni a ninguno de los otros juegos three.js de Foony. Siempre aparecía un error de compilación de shader en Three.js con mensajes como:

  • “Shader Error 1282 - VALIDATE_STATUS false”

Ese mensaje era prácticamente inútil. Normalmente significa “your shader is invalid, good luck”. Genial. Si alguna vez te preguntas por qué en Foony uso mensajes de error únicos para cada error, esta es la razón. Ayuda a localizar el problema en lugar de limitarse a “algo del código se ha roto, arréglalo”.

Pero ¿por qué se rompían shaders de three.js perfectamente válidos? ¿Qué estaba pasando?

La causa real: proxies corrompiendo layout(location = N)

Three.js genera GLSL con qualifiers de layout como:

layout(location = 0) in vec3 position;

Algunos proxies intentan reescribir cualquier cosa que parezca la API location de JavaScript haciendo un reemplazo de cadenas global muy ingenuo. Eso ya es malo en JS, pero además lo estaban haciendo dentro de las cadenas de texto de los shaders. Supongo que parsear un AST les sale demasiado caro.

Así que el código del shader acababa corrompido y se convertía en algo como:

layout(__cpLocation = 0) in vec3 position;

El identificador ahí tiene que ser location. Cualquier otra cosa es GLSL inválido y el compilador lo rechaza. (Layout Qualifiers in GLSL)

Esto es un problema de Three.js solo en el sentido de que Three.js genera shaders de forma dinámica y se los pasamos a WebGL en tiempo de ejecución. El verdadero bug está en la estrategia de reescritura del proxy.

Por qué no «arreglé el proxy»

La idea ingenua sería buscar la cadena de reemplazo de location que usa croxyproxy, __cpLocation, y sustituirla por location. Pero distintos proxies usan nombres de reemplazo distintos. Algunos usan __cpLocation, otros usan identificadores rarísimos. Así que poner un apaño del tipo “reemplaza __cpLocation por location” sería muy frágil.

Necesitaba:

  • Un arreglo genérico (sin hardcodear identificadores de proxies).
  • Un arreglo que funcionara incluso si el proxy estaba reescribiendo también la palabra location en mi JavaScript.

El truco del base64: esconder la palabra location del proxy

Si el proxy reescribe cada literal location que ve, el movimiento más simple es no usar el literal location. Fácil. Ya había visto trucos de este tipo en Lua cuando estuve haciendo ingeniería inversa del sistema de protección de guías de RestedXP (si no recuerdo mal, ofuscan su uso de BNGetInfo, por ejemplo _G("\x42\x4E\x47\x65\x74\x49\x6E\x66\x6F")).

Este truco también funciona en JavaScript, claro. En client/index.html, decodifico lo siguiente en tiempo de ejecución:

// Como estos proxies intentan reemplazar cada `location`, usamos una cadena codificada en base64.
const suffix = 'pb24=';
const locStr = atob('bG9jYXR' + suffix); // "location"
const loc = window[locStr]; // window.location

Ese atob() se ejecuta después de que el proxy ya haya hecho su reescritura de HTML/JS, así que no puede “pre-corromper” la cadena. Parto la cadena en dos para que sea aún más difícil de detectar y uso 'atob' porque puedo, pero String.fromCharCode o escapar en hexadecimal window['\x6c\x6f\x63\x61\x74\x69\x6f\x6e'] también podrían servir.

El patrón del shader roto es siempre estructuralmente el mismo:

layout(<something> = <number>)

Así que lo busco de forma genérica y sustituyo <something> por el identificador correcto:

source.replace(/layout\s*\(\s*[^=)]+\s*=\s*(\d+)\s*\)/g, 'layout(' + locStr + ' = $1)');

El hook de WebGL: parchear shaderSource (WebGL1 + WebGL2)

Como Three.js llama a gl.shaderSource(shader, source), parcheo el propio shaderSource:

const originalShaderSource = WebGLRenderingContext.prototype.shaderSource;

Object.defineProperty(WebGLRenderingContext.prototype, 'shaderSource', {
  value: function (shader, source) {
    return originalShaderSource.call(this, shader, fixCorruptedShaderSource(source));
  },
  writable: true,
  configurable: true,
});

Y aplico el mismo parche a WebGL2RenderingContext si existe.

En cuanto tuve eso en su sitio, los errores de compilación de shaders desaparecieron. En este punto, croxyproxy funcionaba pero proxyorb seguía fallando. ¿Por qué? ¿No debería funcionar igual?


Segunda pasada: el segundo problema (dominios) y por qué quitar foony.io lo hizo todo mucho más fácil

Históricamente Foony usaba dos dominios, al menos durante el último mes:

  • foony.com para el sitio principal
  • foony.io para los assets estáticos

La razón original era práctica: servir assets desde un dominio sin cookies evita que tengas que subir cabeceras de cookies en cada petición de archivo estático. Esto está muy bien, pero no es tan necesario como parece si tenemos en cuenta que HTTP/2 usa HPACK para reducir los bytes enviados en las cabeceras.

Es una optimización válida en navegación normal.

Detrás de proxies se convirtió en una fuente enorme de roturas. Y a la base de usuarios de Foony le encantan los proxies. suspiro

Los proxies tratan «el sitio principal» distinto de «otros sitios»

Muchos proxies están optimizados para “proxyficar esta única página / dominio”. Cargan bien el HTML principal, inyectan scripts, registran su propio ServiceWorker, etc.

Pero cuando la app empieza a pedir assets desde un origen distinto (como foony.io), entras en un festival de cosas que se rompen:

  • Fallos de interceptación del ServiceWorker tipo:
    • “ServiceWorker intercepted the request and encountered an unexpected error”
    • “Loading failed for the module with source”
  • Parámetros de query requeridos por la infraestructura del proxy (y fáciles de eliminar sin querer).
  • Peticiones de assets que pierden los metadatos internos de enrutado del proxy.
  • Proxies raros que sustituyen toda la petición por algo tipo generic-php-slug.php?someQueryParam=hugeEncodedString (sí, no me molesté en soportar eso).

Los mecanismos internos de estos proxies dependen de parámetros de consulta / codificación en la URL y son bastante frágiles.

Uno de los ejemplos más claros era una URL de recurso como:

https://<proxy-ip>/assets/firebase-<hash>.js?__pot=aHR0cHM6Ly9mb29ueS5jb20

Ese ?__pot=... es el estado/enrutado propio del proxy que le dice a qué dominio pertenece la petición. Si lo eliminas, los proxies no pueden resolver la petición correctamente y acabas en la ruta de error del ServiceWorker.

«Intercambio de recursos» al rescate (y por qué se complicó muy rápido)

En un momento dado probé un apaño: detectar “estamos detrás de un proxy” y, entonces, cambiar cualquier URL de recurso foony.io al origen actual para que el proxy viera todo como same-origin.

Suena razonable, y con croxyproxy funcionaba, pero añadía un montón de complejidad:

  • Tienes que reemplazar las etiquetas link y script que ya existen en el HTML.
  • Necesitas un MutationObserver para gestionar las etiquetas que se inyectan dinámicamente (modulepreload, hojas de estilo, etc.).
  • Tienes que conservar los parámetros de query del proxy o rompes su enrutado. Y cada proxy hace esto de forma diferente. Porque claro que sí.
  • Y además tienes que mantener la lógica genérica (sin globals específicos de proxies) para que el código no se convierta en un monstruo imposible de mantener.

Aquí es donde volvió a salir el “truco del base64”: incluso en mi propio JavaScript, tenía que tener cuidado con la cadena literal location porque el proxy podía reescribirla.

Ingeniería inversa del script inyectado por CroxyProxy

Llegados a este punto me entró la curiosidad: ¿qué está haciendo realmente el proxy con mi página? ¿Está inyectando sus propios anuncios? ¿Algo peor?

El script de cliente de CroxyProxy está fuertemente ofuscado.

(new Function(new TextDecoder('utf-8').decode(new Uint8Array((atob('NjY3NTZlN...')).match(/.{1,2}/g).map(b => parseInt(b, 16))))))();

Que, al ejecutarse, da como resultado:

function a0_0x5ebf(_0x213dc9,_0x1c49b6){var _0x4aa7c1=a0_0x4274();return a0_0x5ebf=function(_0x159600,_0x51d898){_0x159600=...

Por lo que se ve, croxyproxy está usando Obfuscator.io para esa ofuscación. Por suerte es relativamente fácil de desofuscar con webcrack.

El resultado es un JavaScript mucho más legible:

((_0x15ca2c, _0x489eaa) => {
  if (typeof module == "object" && module.exports) {
    module.exports = _0x489eaa(require("./punycode"), require("./IPv6"), require("./SecondLevelDomains"));
  } else if (typeof define == "function" && define.amd) {
    define(["./punycode", "./IPv6", "./SecondLevelDomains"], _0x489eaa);
  } else {
    _0x15ca2c.URI = _0x489eaa(_0x15ca2c.punycode, _0x15ca2c.IPv6, _0x15ca2c.SecondLevelDomains, _0x15ca2c);
  }
})(this, function (_0x724467, _0x275183, _0x219d84, _0x2dcc2c) {
  var _0x114c1e = _0x2dcc2c && _0x2dcc2c.URI;
  function _0x5a9187(_0x2fd4ea, _0x3bd460) {
    var _0x23a83f = arguments.length >= 1;
    if (!(this instanceof _0x5a9187)) {

Bien. Ahora sí podemos ver lo que hace. Y… en general parece bastante aceptable. Creo que la ofuscación es sobre todo para evitar que se detecte fácilmente el proxy. En su mayoría.

Tenemos algo de inyección de anuncios / UI:

    Bi(_0x308e2f) {
      console.log("Ads: " + _0x331b11.showAds);
      console.log("Ad codes: " + !!_0x331b11.adsJson);
      console.log("Adblock: " + _0x308e2f);
      _0x34fa18.document.body.insertAdjacentHTML("afterbegin", _0x331b11.modal);
      if (_0x331b11.header) {
        if (_0x331b11.header) {
          this.Ri(_0x308e2f);
        }
        [...document.querySelectorAll("#__cpsHeader a")].forEach(_0xcce595 => {
          _0xcce595.addEventListener("click", function (_0x3ef5f1) {
            if (this.target === "_blank") {
              _0x34fa18.open(this.href, "_blank").focus();
            } else {
              _0x34fa18.location.href = this.href;
            }
            _0x3ef5f1.stopImmediatePropagation();
            _0x3ef5f1.preventDefault();
          }, true);
        });
      }
      return this;
    }

Hay más sitios parecidos, pero básicamente se dedica a mostrar anuncios, incluidos anuncios tipo pop-under. También usa FuckAdBlock.

El reemplazo real de cadenas, sin embargo, ocurre en el servidor. Y quién sabe qué más estará haciendo ahí.

En cualquier caso, no deberías usar proxies web si te importa la seguridad de tus cuentas. Y si no tienes más remedio, evita meter cualquier dato personal / de cuenta / de pago.

«Intercambio de recursos» al cubo de la basura

Decidí que la complejidad del intercambio de recursos, sumada a la complejidad de otras partes del código para soportar foony.io, no merecía el pequeño ahorro de red de tener peticiones bonitas sin cookies. Además estábamos viendo una caída inexplicable en conversiones a partida desde que adoptamos foony.io, así que sospecho que había otros problemas con foony.io de los que ni siquiera éramos conscientes.

Así que quité foony.io. Al menos por ahora.

En cuanto eliminé la lógica del CDN foony.io y lo unifiqué todo en foony.com, el soporte de proxies se simplificó muchísimo:

  • Cargas de assets same-origin.
  • Menos “casos especiales” que explicarle al ServiceWorker del proxy.
  • Menos reescrituras.
  • Código menos frágil.

En resumen, quitar foony.io fue una simplificación de arquitectura que redujo la superficie para comportamientos raros de proxies.


Tercera pasada: qué funciona, qué no, y por qué

Proxies que sabemos que funcionan

A estas alturas, Foony funciona detrás de:

  • croxyproxy
  • proxyorb

Seguro que algunos otros proxies también funcionan. Apuesto a que la mayoría todavía no. Pero al menos los importantes que la gente usa para jugar parecen funcionar.

¿Por qué no “todos los proxies”?

Hay proxies que simplemente no pueden soportar una aplicación web multijugador moderna. Por ejemplo:

  • Proxies que no soportan HTTPS correctamente.
  • Proxies que rompen o bloquean WebSockets (Foony usa red en tiempo real). Técnicamente se podría rodear esto, pero añadiría bastante complejidad.
  • Proxies con demasiadas restricciones sobre peticiones cross-origin, cabeceras o ServiceWorkers.

Ideas clave

Los proxies web son muy inseguros

Son middleware que:

  • reescriben HTML
  • reescriben JavaScript
  • a veces inyectan un ServiceWorker
  • y a menudo dependen de parámetros de query / codificación en la URL para enrutar peticiones
  • pueden trastear tu página de mil maneras distintas

Me sorprendió lo profundo que llegan algunos proxies: reescriben cadenas de código de shader, comentarios y vete tú a saber qué más.

A veces el mejor arreglo es de arquitectura

El parche de WebGL hizo que los juegos volvieran a renderizar, pero quitar la estrategia de CDN multidominio hizo que el soporte de proxies se mantuviera estable.

Es un buen recordatorio: las optimizaciones ingeniosas pueden ser perfectamente razonables hasta que chocan con un middleware hostil. O con las extensiones del navegador de los usuarios. O con Safari. O con la configuración de idioma. O con las opciones de accesibilidad. O con las llamaradas solares. O con cualquier cosa, realmente.


Conclusión

Foony ahora funciona detrás de los proxies que importan (croxyproxy y proxyorb), sin convertir la base de código en un desastre lleno de hacks específicos de proxies:

  • Un arreglo genérico para los shaders de Three.js (sin identificadores específicos de proxies).
  • Una estrategia de dominios más simple (foony.com en todas partes).
8 Ball Pool online multiplayer billiards icon