background blurbackground mobile blur

1/1/1970

Як я змусив Foony працювати через проксі

Привіт! Я давно знаю, що вебпроксі часто створюють проблеми з сумісністю сайтів. Але підтримка Foony в проксі була особливо поганою, і розв’язати проблему сумісності Foony з проксі виявилося доволі хитрою задачкою.

Це теж не історія про «Foony використовує якісь дивні API» (хоча так, використовує). Це була комбінація з:

  • Проксі, які роблять агресивну підстановку рядків у місцях, де взагалі не можна це робити.
  • Проксі, які по-різному обробляють основний домен сайту та «інші» домени (CDN, хостинг статичних ресурсів тощо).
  • І суворої реальності, що деякі проксі фізично не здатні тягнути сучасні вебдодатки (коректний HTTPS, WebSockets тощо).

Ми не працюємо з усіма проксі, але зараз Foony успішно працює щонайменше з croxyproxy і proxyorb, а саме це і було ціллю.

Нижче розповім, що ламалося, чому воно ламалося і які саме виправлення справді мали значення.


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

Симптом

Коли я спробував croxyproxy, я не зміг пограти в 8 Ball Pool чи будь-яку з Foony’них інших three.js ігор. Я постійно ловив помилку компіляції шейдера в 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 для статичних ресурсів

Початкова причина була практичною: якщо віддавати ресурси з домену без cookie, браузер не надсилає cookies у кожному запиті до статичних файлів, і заголовки менше роздуваються. Це круто, але не настільки критично, як може здаватися, особливо враховуючи, що 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”
  • Параметри запиту, які потрібні інфраструктурі проксі (і які дуже легко випадково зрізати).
  • Запити до ресурсів втрачають внутрішні метадані маршрутизації, які потрібні проксі.
  • Дивні проксі, що повністю підміняють запит чимось типу generic-php-slug.php?someQueryParam=hugeEncodedString (таке я вже навіть не намагався підтримувати).

Внутрішні механізми цих проксі часто залежать від query-параметрів та кодування URL, і це все дуже крихке.

Один з показових прикладів - URL ресурсу:

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

Оцей ?__pot=... - це внутрішній стан/маршрутизація проксі, який каже проксі, для якого домену цей запит. Якщо його зрізати, проксі вже не розуміє, куди слати запит, і ви потрапляєте у гілку помилки ServiceWorker’а.

“Підміна ресурсів” на допомогу (і чому це швидко перетворилося на хаос)

У якийсь момент я спробував такий обхідний шлях: детектити, що «ми за проксі», а потім усі URL ресурсів з foony.io підміняти на поточний origin, щоб проксі бачив усе як same-origin.

Звучить логічно і справді працювало з croxyproxy, але додало купу складності:

  • Треба замінити link і script теги, які вже є в HTML.
  • Треба ставити 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)) {

Красота. Тепер можна подивитися, що воно робить. І... загалом усе виглядає досить невинно. Думаю, обфускація тут здебільшого для того, щоб ускладнити детектування проксі. Переважно.

Ось трохи коду, що інжектить рекламу / інтерфейс:

    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 не варті невеликої економії трафіку від красивих запитів без cookies. Ми також помітили незрозуміле падіння конверсій у гру після переходу на foony.io, тож, підозрюю, там були ще якісь проблеми, про які ми навіть не знали.

Тому я прибрав foony.io. Принаймні поки що.

Щойно я видалив логіку CDN foony.io і стандартизував усе на foony.com, підтримка проксі стала набагато простішою:

  • Завантаження ресурсів з того самого origin.
  • Менше «особливих випадків», які треба пояснювати ServiceWorker’у проксі.
  • Менше переписувань URL.
  • Менше крихкого коду.

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


Третій прохід: що працює, що ні і чому

Підтверджено працюючі проксі

На цей момент Foony працює через:

  • croxyproxy
  • proxyorb

Швидше за все, деякі інші проксі теж працюють. Підозрюю, що більшість усе одно ні. Але принаймні найважливіші, якими люди реально користуються, щоб грати, - працюють.

Чому не “всі проксі”?

Деякі проксі просто не здатні підтримувати сучасний мультиплеєрний вебдодаток. Наприклад:

  • Проксі, які некоректно підтримують HTTPS.
  • Проксі, які ламають або блокують WebSockets (Foony використовує мережу в реальному часі). Теоретично це можна обійти, але ціною великої складності.
  • Проксі з надто жорсткими обмеженнями на cross-origin запити, заголовки чи ServiceWorker’и.

Основні висновки

Вебпроксі дуже небезпечні

Це проміжна ланка, яка:

  • переписує HTML,
  • переписує JavaScript,
  • інколи інжектить власний ServiceWorker,
  • часто залежить від query-параметрів / кодування URL для маршрутизації запитів,
  • і може крутити вашу сторінку як завгодно.

Я справді здивувався, наскільки глибоко деякі проксі лізуть: вони переписують навіть рядки шейдерів, коментарі і, мабуть, ще купу всього.

Інколи найкраще рішення - змінити архітектуру

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

Це гарне нагадування: розумні оптимізації можуть бути цілком логічними, доки не зіштовхнуться з ворожим «проміжним прошарком». Або з браузерними розширеннями користувача. Або з Safari. Або з мовними налаштуваннями. Або з фічами доступності. Або зі спалахами на Сонці. Або взагалі з чим завгодно.


Висновок

Зараз Foony працює через ті проксі, які справді мають значення (croxyproxy і proxyorb), і при цьому кодова база не перетворилася на купу проксі-специфічних костилів:

  • Загальне виправлення шейдерів Three.js (без ідентифікаторів, прив’язаних до конкретних проксі).
  • Простіша стратегія з доменами (усюди foony.com).
8 Ball Pool online multiplayer billiards icon