background blurbackground mobile blur

1/1/1970

Como Fiz o Foony Funcionar Atrás de Proxies

E aí! Eu já sabia há um bom tempo que web proxies causam problemas de compatibilidade em sites. Mas o suporte do Foony em proxies era notoriamente ruim, e resolver a compatibilidade do Foony com proxies foi bastante complicado.

Também não é um caso de "Foony usa APIs exóticas" (mesmo que a gente use). Foi uma combinação de:

  • Proxies fazendo reescrita agressiva de strings em lugares onde absolutamente não deveriam.
  • Proxies tratando o domínio principal do site de forma diferente de "outros" domínios (CDNs, hosts de assets, etc).
  • E a dura realidade de que alguns proxies simplesmente não conseguem suportar aplicações web modernas (HTTPS correto, WebSockets, etc).

Não funcionamos com todo proxy, mas agora funcionamos pelo menos com croxyproxy e proxyorb, que era o objetivo.

Abaixo eu explico o que quebrou, por que quebrou e os ajustes que realmente importaram.


Etapa 1: Shaders válidos do Three.js, mas quebrados

O sintoma

Quando testei o croxyproxy, eu não conseguia jogar 8 Ball Pool nem nenhum outro jogo three.js do Foony. Eu ficava recebendo uma falha de compilação de shader no Three.js com erros como:

  • "Shader Error 1282 - VALIDATE_STATUS false"

Essa mensagem era quase totalmente inútil. Geralmente significa "seu shader é inválido, boa sorte". Maravilha. Se você já se perguntou por que eu sempre uso mensagens de erro únicas para cada erro do Foony, é por isso. Ajuda a localizar problemas em vez de só dizer "código quebrado, conserta aí".

Mas por que shaders perfeitamente válidos do three.js estavam quebrando? O que houve?

A causa real: proxies corrompendo layout(location = N)

O Three.js gera GLSL com qualificadores de layout assim:

layout(location = 0) in vec3 position;

Alguns proxies tentam reescrever qualquer coisa que pareça com a API location do JavaScript fazendo substituição global ingênua de strings. Isso já é ruim em JS, mas eles também faziam isso dentro de strings de código-fonte de shaders. Acho que parsing de AST é caro demais para eles.

Então o código-fonte do shader ficava corrompido em algo como:

layout(__cpLocation = 0) in vec3 position;

O identificador tem que ser location ali. Qualquer outra coisa é GLSL inválido, e o compilador rejeita. (Layout Qualifiers in GLSL)

Isso só é um problema do Three.js no sentido de que o Three.js gera shaders dinamicamente, e nós passamos eles para o WebGL em tempo de execução. O bug real é a estratégia de reescrita do proxy.

Por que eu não "consertei o proxy"

Uma abordagem ingênua seria procurar pela string de substituição do location do croxyproxy, __cpLocation, e trocar de volta por location. Mas proxies diferentes usam nomes de substituição diferentes. Alguns usam __cpLocation, outros usam outros identificadores estranhos. Então fazer um fix hardcoded tipo "trocar __cpLocation de volta para location" é frágil.

Eu precisava de:

  • Um fix genérico (sem hardcoding de identificadores de proxy).
  • Um fix que funcione mesmo se o proxy estiver reescrevendo a palavra location no meu JavaScript também.

O truque do base64: escondendo a palavra location do proxy

Se o proxy reescreve todo location literal que vê, o movimento mais simples é simplesmente não usar location. Fácil. Já vi truques assim antes em Lua quando fiz engenharia reversa do sistema de proteção de guias do RestedXP (se bem me lembro, eles ofuscam o uso do BNGetInfo, tipo _G("\x42\x4E\x47\x65\x74\x49\x6E\x66\x6F")).

Esse truque funciona em JavaScript também, claro. No client/index.html, eu decodifico o seguinte em runtime:

// Como esses proxies tentam substituir cada `location`, usamos uma string codificada em base64.
const suffix = 'pb24=';
const locStr = atob('bG9jYXR' + suffix); // "location"
const loc = window[locStr]; // window.location

Esse atob() acontece depois que o proxy já fez sua reescrita de HTML/JS, então ele não consegue "pré-corromper" a string. Eu divido a string em duas para tornar a detecção ainda mais difícil, e uso 'atob' porque posso, mas String.fromCharCode ou hex-escaping window['\x6c\x6f\x63\x61\x74\x69\x6f\x6e'] também poderia funcionar.

O padrão do shader quebrado é sempre estruturalmente o mesmo:

layout(<algo> = <número>)

Então eu faço o match disso de forma genérica e substituo <algo> pelo identificador correto:

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

O hook do WebGL: fazer patch em shaderSource (WebGL1 + WebGL2)

Como o Three.js chama gl.shaderSource(shader, source), eu faço patch no próprio 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,
});

E aplico o mesmo patch no WebGL2RenderingContext se ele existir.

Depois que isso entrou no lugar, os erros de compilação de shader sumiram. Nesse ponto, o croxyproxy estava funcionando, mas o proxyorb ainda estava falhando. Por quê?! Não deveria funcionar do mesmo jeito?


Etapa 2: O segundo problema (domínios) e por que remover o foony.io deixou tudo mais fácil

O Foony historicamente usava dois domínios, pelo menos no último mês:

  • foony.com para o site principal
  • foony.io para assets estáticos

A razão original era prática: servir assets de um domínio sem cookies evita o inchaço de upload de cabeçalhos de cookies em cada requisição de arquivo estático. Isso é ótimo, mas não tão necessário quanto você pensaria, dado que o HTTP/2 usa HPACK para reduzir bytes enviados pela rede em cabeçalhos.

É uma otimização válida em navegação normal.

Atrás de proxies, virou uma grande fonte de quebras. E a base de usuários do Foony adora proxies. suspiro

Proxies tratam "o site principal" diferente de "outros sites"

Muitos proxies são otimizados para "fazer proxy desta única página / domínio". Eles vão carregar o HTML principal com sucesso, injetar scripts, registrar o próprio ServiceWorker, etc.

Mas quando o app começa a puxar assets de uma origem diferente (como foony.io), você cai em todo tipo de quebradeira divertida:

  • Falhas de interceptação do ServiceWorker tipo:
    • "ServiceWorker intercepted the request and encountered an unexpected error"
    • "Loading failed for the module with source"
  • Query params sendo exigidos pela infraestrutura do proxy (e fáceis de remover acidentalmente).
  • Requisições de assets perdendo os metadados internos de roteamento do proxy.
  • Proxies estranhos que substituem a requisição inteira por generic-php-slug.php?someQueryParam=hugeEncodedString (é, não me dei o trabalho de suportar isso).

Esses mecanismos internos dos proxies dependem de query params / URL encoding, e são bem frágeis.

Um dos exemplos mais reveladores foi uma URL de asset assim:

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

Esse ?__pot=... é o roteamento/estado do próprio proxy que diz a ele para qual domínio é a requisição. Se você remove, os proxies não conseguem resolver a requisição corretamente, e você acaba caindo no caminho de erro do ServiceWorker.

"Resource swapping" salvando o dia (e por que ficou complicado rapidinho)

Em certo momento, tentei uma solução: detectar "estamos sob proxy" e trocar quaisquer URLs de recursos foony.io para a origem atual, para o proxy ver tudo como mesma origem.

Isso parece razoável, e funcionou no croxyproxy, mas adicionou bastante complexidade:

  • Você precisa substituir tags link e script que já existem no HTML.
  • Você precisa de um MutationObserver para lidar com tags injetadas dinamicamente (modulepreload, stylesheet, etc).
  • Você tem que preservar os query params do proxy, ou quebra o roteamento dele. E proxies diferentes fazem isso de forma diferente. Claro que sim.
  • E você ainda tem que manter a lógica genérica (sem globais específicos de proxy) para que o código não vire um lixão inchado.

Esse foi também onde o "truque do base64" apareceu de novo: mesmo no meu próprio JavaScript, eu tinha que ter cuidado com a string literal location porque o proxy poderia reescrevê-la.

Engenharia reversa do script injetado pelo CroxyProxy

Nesse ponto fiquei curioso: o que exatamente o proxy está fazendo na minha página? Está injetando os anúncios dele? Algo pior?

O script client-side do CroxyProxy é fortemente ofuscado.

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

Que ao ser executado, resulta em:

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

Com base nisso, parece que o croxyproxy está usando o Obfuscator.io para essa ofuscação. Que felizmente é fácil o suficiente de desofuscar com o webcrack.

Isso resulta em um JavaScript bem mais legível:

((_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)) {

Bacana. Agora dá pra ver o que está fazendo. E... parece ok no geral. Acho que a ofuscação serve principalmente para ajudar a evitar a detecção do proxy. Principalmente.

Tem um pouco de injeção de anúncios / 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;
    }

Tem outros pontos, mas basicamente é só mostrando anúncios, incluindo anúncios estilo pop-under. Também usa o FuckAdBlock.

A substituição real das strings, no entanto, está acontecendo no servidor. E sabe-se o que tudo isso faz.

De qualquer forma, você absolutamente não deveria estar usando web proxies se se preocupa com a segurança da sua conta. Se precisar, evite digitar qualquer informação pessoal / de conta / de compra.

"Resource swapping" no lixo

Decidi que a complexidade do resource swapping, somada à complexidade em outras partes do código para suporte ao foony.io, não valia a pequena economia de rede de requisições bonitinhas e sem cookies. Também estávamos vendo uma queda inexplicada nas nossas conversões de jogabilidade desde que adotamos o foony.io, então suspeito que havia outros problemas com foony.io dos quais não tínhamos consciência.

Então removi o foony.io. Pelo menos por enquanto.

Depois que apaguei a lógica do CDN foony.io e padronizei tudo no foony.com, o suporte a proxy ficou drasticamente mais simples:

  • Carregamentos de assets na mesma origem.
  • Menos "casos especiais" para explicar a um ServiceWorker de proxy.
  • Menos reescrita.
  • Código menos frágil.

Resumindo, remover o foony.io foi uma simplificação arquitetural que reduziu a área de superfície para comportamentos estranhos de proxy.


Etapa 3: O que funciona, o que não funciona e por quê

Proxies confirmados como funcionais

Nesse ponto, o Foony funciona atrás de:

  • croxyproxy
  • proxyorb

Provavelmente alguns outros proxies funcionam. Aposto que a maioria ainda não funciona. Mas pelo menos os mais importantes, que as pessoas usam para jogar, parecem funcionar.

Por que não "todos os proxies"?

Alguns proxies simplesmente não conseguem suportar um app web multiplayer moderno. Exemplos:

  • Proxies que não suportam HTTPS adequadamente.
  • Proxies que quebram ou bloqueiam WebSockets (o Foony usa rede em tempo real). Tecnicamente dá pra contornar isso, mas adicionaria complexidade.
  • Proxies que têm restrições demais em torno de requisições cross-origin, cabeçalhos ou ServiceWorkers.

Principais aprendizados

Web proxies são muito inseguros

Eles são middlewares que:

  • reescrevem HTML
  • reescrevem JavaScript
  • às vezes injetam um ServiceWorker
  • e frequentemente dependem de query params / URL encoding para rotear requisições
  • podem mexer nas suas páginas de várias formas

Fiquei surpreso com o quão fundo alguns proxies vão: eles reescrevem strings de código-fonte de shaders, comentários, e Deus sabe o que mais.

Às vezes, o melhor fix é arquitetural

O patch do WebGL fez os jogos renderizarem de novo, mas remover a estratégia de CDN multi-domínio fez o suporte a proxy continuar estável.

É um bom lembrete: otimizações espertas podem ser perfeitamente razoáveis até colidirem com middleware hostil. Ou extensões de navegador do usuário. Ou Safari. Ou configurações de idioma. Ou recursos de acessibilidade. Ou explosões solares. Ou qualquer coisa, na real.


Conclusão

O Foony agora funciona atrás dos proxies que importam (croxyproxy e proxyorb), sem transformar o codebase em uma bagunça específica de proxies:

  • Um fix genérico de shader do Three.js (sem identificadores específicos de proxy).
  • Uma estratégia de domínio mais simples (foony.com em todo lugar).
8 Ball Pool online multiplayer billiards icon