background blurbackground mobile blur

1/1/1970

Как я заставил Foony работать за прокси-серверами

Привет! Я давно знаю, что веб-прокси часто создают проблемы совместимости для сайтов. Но поддержка Foony при работе через прокси была особенно ужасной, и починить совместимость Foony с прокси оказалось довольно нетривиально.

И это не история в духе “Foony использует какие-то дикие API” (хотя мы и правда используем). Тут сработала комбинация факторов:

  • Прокси агрессивно переписывают строки там, где им вообще не стоит лезть.
  • Прокси по-разному относятся к основному домену сайта и “остальным” доменам (CDN, хосты ассетов и т. п.).
  • И суровая правда в том, что часть прокси просто не умеет нормально работать с современными веб-приложениями (корректный HTTPS, WebSockets и так далее).

Мы не работаем со всеми прокси, но сейчас всё стабильно работает хотя бы с croxyproxy и proxyorb. Это и было целью.

Ниже я расскажу, что ломалось, почему ломалось и какие именно правки реально помогли.


Первый заход: валидные, но сломанные шейдеры Three.js

Симптом

Когда я попробовал croxyproxy, у меня не получилось поиграть ни в 8 Ball Pool, ни в другие three.js игры на Foony.
Я постоянно получал ошибку компиляции шейдера в Three.js с чем-то вроде:

  • “Shader Error 1282 - VALIDATE_STATUS false”

Сообщение практически бесполезное. Обычно оно означает “шейдер невалидный, удачи”. Чудесно. Если вы когда-нибудь задумывались, почему я в Foony использую уникальные сообщения для каждой ошибки, вот ответ. Это очень помогает понять, где именно всё сломалось, вместо абстрактного "код не работает, иди чини".

Но почему совершенно валидные шейдеры three.js вдруг начали ломаться? В чём дело?

Настоящая причина: прокси портят layout(location = N)

Three.js генерирует GLSL с такими layout-квалификаторами:

layout(location = 0) in vec3 position;

Некоторые прокси пытаются переписать всё, что похоже на JavaScript API location, просто делая наивную глобальную замену строк. Это уже плохо для JS, но они при этом переписывали и строки с текстом шейдера. Видимо, парсить AST для них слишком дорого.

В итоге исходник шейдера превращался примерно в такое:

layout(__cpLocation = 0) in vec3 position;

А идентификатор там обязан быть location. Любое другое слово делает GLSL невалидным, и компилятор его отклоняет. (Layout Qualifiers in GLSL)

Это проблема Three.js лишь в том смысле, что Three.js динамически генерирует шейдеры, а потом передаёт их в WebGL во время работы. Настоящий баг - это стратегия переписывания строк у прокси.

Почему я не “починил прокси”

Наивное решение: найти замену location от croxyproxy, то есть __cpLocation, и превратить её обратно в location. Но разные прокси подставляют разные имена. Одни используют __cpLocation, другие какие-нибудь ещё более странные идентификаторы. Так что жёстко зашивать в код "заменить __cpLocation на location" - очень хрупкий вариант.

Мне нужно было:

  • Общее решение (без хардкода под конкретные прокси).
  • Такое, которое будет работать, даже если прокси переписывает слово location и в моём JavaScript тоже.

Трюк с base64: прячем слово location от прокси

Если прокси переписывает каждое встреченное location, самый простой ход - просто не писать location в явном виде. Вполне решаемо. Я уже видел подобные трюки в Lua, когда разбирал систему защиты гайдов у RestedXP (если правильно помню, они обфусцировали вызов BNGetInfo, что-то вроде _G("\x42\x4E\x47\x65\x74\x49\x6E\x66\x6F")).

Тот же приём прекрасно работает и в JavaScript. В client/index.html я декодирую такую штуку во время выполнения:

// Эти прокси пытаются заменить каждое `location`, поэтому используем строку в base64.
const suffix = 'pb24=';
const locStr = atob('bG9jYXR' + suffix); // "location"
const loc = window[locStr]; // window.location

atob() вызывается после того, как прокси уже закончил переписывать HTML и JS, так что он не может “испортить” строку заранее. Я ещё и разделил строку на две части, чтобы её сложнее было обнаружить. Использую 'atob' просто потому, что могу, но String.fromCharCode или hex-экранирование window['\x6c\x6f\x63\x61\x74\x69\x6f\x6e'] тоже подошли бы.

Сломанный кусок шейдера всегда имеет одну и ту же структуру:

layout(<что-то> = <число>)

Так что можно просто матчить этот паттерн и заменять <что-то> на правильный идентификатор:

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

Хук в WebGL: патчим shaderSource (WebGL1 + WebGL2)

Так как Three.js вызывает gl.shaderSource(shader, source), я патчу сам 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,
});

И такую же правку я применяю к WebGL2RenderingContext, если он есть.

После этого ошибки компиляции шейдеров исчезли. На этом этапе с croxyproxy всё уже работало, но proxyorb всё ещё падал. Почему?! Разве оно не должно работать точно так же?


Второй заход: проблема с доменами и почему удаление foony.io всё упростило

Исторически Foony за последний месяц использовал два домена:

  • foony.com для основного сайта
  • foony.io для статических ассетов

Причина была практичной: раздавать ассеты с домена без куки - значит не слать лишние заголовки с куками при каждом запросе статики. Это круто, но не настолько критично, как может показаться, учитывая что HTTP/2 использует HPACK для сжатия заголовков.

В обычном браузинге это полезная оптимизация.

Но за прокси это превратилось в серьёзный источник поломок. А пользователи Foony очень любят прокси. вздох

Прокси по-разному относятся к “основному сайту” и “другим сайтам”

Многие прокси заточены под сценарий “проксируй вот эту одну страницу / домен”. Они аккуратно подгружают основной HTML, внедряют свои скрипты, регистрируют свой ServiceWorker и так далее.

Но как только приложение начинает тянуть ассеты с другого origin (например, foony.io), начинается весёлый фестиваль багов:

  • Сбои перехвата в ServiceWorker, наподобие:
    • “ServiceWorker intercepted the request and encountered an unexpected error”
    • “Loading failed for the module with source”
  • Проксируемая инфраструктура может требовать свои query-параметры, которые очень легко случайно убрать.
  • Запросы ассетов теряют внутренние метаданные маршрутизации прокси.
  • Странные прокси могут вообще превратить любой запрос в вид generic-php-slug.php?someQueryParam=hugeEncodedString (я даже не стал пытаться это поддерживать).

Вся их внутренняя магия живёт на query-параметрах и кодировке URL, и это всё очень хрупко.

Характерный пример - URL ассета в духе:

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

Этот ?__pot=... - собственное состояние/маршрутизация прокси, по которому он понимает, для какого домена на самом деле был запрос. Если его убрать, прокси уже не может корректно понять, куда идти, и вы попадаете в ветку с ошибкой ServiceWorker.

“Переключение ресурсов” в помощь (и почему всё быстро стало слишком сложным)

В какой-то момент я попробовал такой обходной путь: если мы “за прокси”, то заменять все URL ресурсов с foony.io на текущий origin, чтобы прокси видел всё как запросы к одному и тому же происхождению.

Звучит разумно, и для croxyproxy это действительно сработало, но при этом добавило тонну сложности:

  • Нужно переписать уже существующие в HTML link и script.
  • Нужен MutationObserver, чтобы обрабатывать динамически добавляемые теги (modulepreload, стили и т. п.).
  • Нужно сохранять query-параметры прокси, иначе ломается их маршрутизация. И разные прокси делают это по-разному. Конечно же.
  • И всё это ещё нужно реализовать максимально общим способом (без глобальных переменных под каждый прокси), чтобы код не превратился в раздутую помойку.

На этом этапе снова всплыл “трюк с base64”: даже в своём JavaScript мне приходилось аккуратно обходиться с буквальной строкой location, потому что прокси мог её переписать.

Реверс-инжиниринг скрипта, который внедряет CroxyProxy

В какой-то момент мне стало просто любопытно: а что вообще прокси делает с моей страницей? Вставляет свою рекламу? Что-то похуже?

Клиентский скрипт CroxyProxy сильно обфусцирован.

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

Который при выполнении разворачивается в нечто такое:

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

Судя по виду, croxyproxy использует Obfuscator.io для обфускации. К счастью, это довольно легко снять с помощью webcrack.

После этого получается куда более читаемый JavaScript:

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

Красота. Теперь видно, что там вообще происходит. И... в целом всё выглядит относительно нормально. Думаю, обфускация в основном нужна, чтобы усложнить детектирование прокси. В основном.

Немного кода, отвечающего за рекламу и 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;
    }

Есть ещё несколько похожих мест, но в целом он просто показывает рекламу, включая попандеры. Также он использует FuckAdBlock.

Реальная же подмена строк происходит на сервере. А там кто вообще знает, что именно творится.

Так или иначе, если вам важна безопасность аккаунта, веб-прокси лучше вообще не использовать. Если уж без них совсем никак, не вводите через них свои персональные данные, логины, платежи и прочее.

“Переключение ресурсов” отправляется в корзину

В итоге я решил, что сложность “переключения ресурсов” плюс сложность остального кода, завязанного на поддержку foony.io, не стоит тех небольших сетевых выгод от красивых запросов без кук. Мы ещё и заметили необъяснимое падение конверсий в геймплей после перехода на foony.io, так что, похоже, домен приносил и какие-то ещё скрытые проблемы.

Поэтому я убрал foony.io. По крайней мере, пока.

Как только я вычистил логику CDN для foony.io и стандартизировал всё на foony.com, поддержка прокси стала значительно проще:

  • Все ассеты грузятся с одного origin.
  • Меньше “особых случаев” для ServiceWorker в прокси.
  • Меньше переписываний.
  • Меньше хрупкого кода.

Коротко: отказ от foony.io стал архитектурным упрощением, которое уменьшило площадь соприкосновения с странным поведением прокси.


Третий заход: что работает, что нет и почему

Прокси, с которыми Foony точно работает

Сейчас Foony нормально работает за такими прокси:

  • croxyproxy
  • proxyorb

Скорее всего, какие-то другие прокси тоже будут работать. Подозреваю, что большинство всё ещё нет. Но хотя бы популярные, через которые люди в основном и играют, теперь справляются.

Почему не “со всеми прокси”?

Есть прокси, которые в принципе не могут поддержать современное мультиплеерное веб-приложение. Например:

  • Прокси, которые некорректно поддерживают HTTPS.
  • Прокси, которые ломают или блокируют WebSockets (Foony использует сетевой real-time). Технически это можно было бы обойти, но цена в виде усложнения кода была бы большой.
  • Прокси с избыточными ограничениями на кросс-доменные запросы, заголовки или ServiceWorker.

Основные выводы

Веб-прокси очень небезопасны

Это прослойка, которая:

  • переписывает HTML
  • переписывает JavaScript
  • иногда внедряет свой ServiceWorker
  • часто полагается на query-параметры и кодировку URL для маршрутизации запросов
  • может ковыряться в ваших страницах как угодно и где угодно

Меня поразило, насколько глубоко некоторые прокси лезут: они переписывают даже строки с кодом шейдеров, комментарии и, возможно, ещё Бог знает что.

Иногда лучшее решение - архитектурное

Патч для WebGL снова заставил игры рендериться, но отказ от многодоменной стратегии с CDN сделал поддержку прокси стабильно рабочей.

Хорошее напоминание: хитрые оптимизации могут быть вполне разумными, пока не столкнутся с враждебной прослойкой. Или с браузерными расширениями пользователя. Или с Safari. Или с настройками языка. Или с функциями доступности. Или с солнечными вспышками. Или вообще с чем угодно.


Заключение

Сейчас Foony работает за теми прокси, которые действительно важны (croxyproxy и proxyorb), и при этом кодовая база не превратилась в свалку, завязанную на конкретные прокси:

  • Общий фикс для шейдеров Three.js (без специфичных идентификаторов для отдельных прокси).
  • Более простая стратегия с доменами (везде только foony.com).
8 Ball Pool online multiplayer billiards icon