

1/1/1970
Как я заставил Foony работать за прокси
Привет! Я уже давно знаю, что веб-прокси создают проблемы совместимости для сайтов. Однако поддержка Foony в прокси была печально известна своим качеством, и решение проблемы совместимости Foony с прокси оказалось довольно непростой задачей.
И это не из серии «Foony использует экзотические API» (хотя мы и используем). Это была комбинация факторов:
- Прокси агрессивно переписывают строки там, где этого делать абсолютно не следует.
- Прокси относятся к домену основного сайта иначе, чем к «другим» доменам (CDN, хостинги ассетов и т.д.).
- И суровая реальность: некоторые прокси просто не способны поддерживать современные веб-приложения (корректность HTTPS, WebSockets и т.д.).
Мы работаем не со всеми прокси, но теперь поддерживаем как минимум croxyproxy и proxyorb, что и было целью.
Ниже я объясню, что ломалось, почему ломалось, и какие исправления действительно имели значение.
Этап 1: валидные, но сломанные шейдеры 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(location = 0) in vec3 position;
Некоторые прокси пытаются переписать всё, что похоже на JavaScript-API location, выполняя наивную глобальную замену строк. Это уже плохо в JS, но они делают это и внутри строк исходного кода шейдеров. Видимо, парсинг AST для них слишком дорог.
В итоге исходный код шейдера превращается в нечто такое:
layout(__cpLocation = 0) in vec3 position;
Идентификатор там обязательно должен быть location. Что-либо ещё это невалидный GLSL, и компилятор его отклоняет. (Квалификаторы раскладки в 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 всё ещё падал. Почему?! Разве это не должно работать одинаково?
Этап 2: вторая проблема (домены) и почему удаление foony.io всё упростило
Foony исторически использовал два домена, по крайней мере последний месяц:
foony.comдля основного сайтаfoony.ioдля статических ассетов
Изначальная причина была практической: раздача ассетов с домена без cookies позволяет избежать раздувания заголовков с куками при каждом запросе статического файла. Это здорово, но не настолько необходимо, как может показаться, учитывая, что HTTP/2 использует HPACK для уменьшения количества байтов, отправляемых в заголовках.
При обычном просмотре это валидная оптимизация.
За прокси она стала главным источником поломок. А пользовательская база Foony обожает прокси. эх.
Прокси относятся к «основному сайту» иначе, чем к «другим сайтам»
Многие прокси оптимизированы под «проксирование одной страницы / домена». Они успешно загружают основной HTML, инжектят скрипты, регистрируют свой собственный ServiceWorker и т.д.
Но когда приложение начинает подтягивать ассеты с другого источника (например, 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(да, я не стал заморачиваться с поддержкой такого).
Внутренние механизмы этих прокси зависят от параметров запроса / URL-кодирования и довольно хрупки.
Один из показательных примеров: URL ассета вроде:
https://<proxy-ip>/assets/firebase-<hash>.js?__pot=aHR0cHM6Ly9mb29ueS5jb20
Этот ?__pot=... это собственный механизм маршрутизации/состояния прокси, который сообщает прокси, для какого домена предназначен запрос. Если его срезать, прокси не сможет правильно разрешить запрос, и вы получите ошибку ServiceWorker.
«Подмена ресурсов» спешит на помощь (и почему она быстро усложнилась)
В какой-то момент я попробовал обходное решение: определить, что «мы за прокси», а затем подменять все URL ресурсов с foony.io на текущий источник, чтобы прокси видел всё как same-origin.
Звучит разумно, и для croxyproxy сработало, но добавило кучу сложности:
- Нужно заменить теги
linkиscript, которые уже существуют в HTML. - Нужен
MutationObserverдля обработки динамически инжектируемых тегов (modulepreload, stylesheet и т.д.). - Нужно сохранять параметры запроса прокси, иначе сломается их маршрутизация. И разные прокси делают это по-разному. Конечно же.
- И всё равно надо держать логику обобщённой (без специфичных для прокси глобальных переменных), чтобы код не превратился в раздутую помойку.
Именно здесь снова пригодилась «хитрость с 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;
}
Есть и другие места, но в целом он просто показывает рекламу, в том числе pop-under-формата. Также он использует FuckAdBlock.
Однако настоящая замена строк происходит на стороне сервера. И кто знает, что там вообще творится.
В любом случае, вам категорически не стоит использовать веб-прокси, если вы заботитесь о безопасности своего аккаунта. Если уж приходится, избегайте ввода любых персональных данных / информации об аккаунте / платёжных реквизитов.
«Подмена ресурсов» в мусорку
Я решил, что сложность подмены ресурсов в сочетании со сложностью в других частях кода для поддержки foony.io не стоит небольшой экономии трафика на красивых запросах без cookies. Мы также наблюдали необъяснимое падение конверсии в игровой процесс с момента внедрения foony.io, поэтому я подозреваю, что с foony.io были и другие проблемы, о которых мы не подозревали.
Так что я удалил foony.io. Хотя бы пока что.
Как только я удалил CDN-логику foony.io и стандартизировал всё на foony.com, поддержка прокси стала значительно проще:
- Загрузка ассетов с того же origin.
- Меньше «особых случаев», которые надо объяснять ServiceWorker прокси.
- Меньше переписывания.
- Менее хрупкий код.
Короче говоря, удаление foony.io стало архитектурным упрощением, которое уменьшило поверхность для странного поведения прокси.
Этап 3: что работает, что нет, и почему
Подтверждённые рабочие прокси
На данный момент Foony работает за:
- croxyproxy
- proxyorb
Некоторые другие прокси, вероятно, тоже работают. Готов поспорить, большинство всё ещё нет. Но по крайней мере важные, через которые люди играют, кажется, работают.
Почему не «все прокси»?
Некоторые прокси просто не могут поддерживать современное многопользовательское веб-приложение. Примеры:
- Прокси, которые не поддерживают HTTPS должным образом.
- Прокси, которые ломают или блокируют WebSockets (Foony использует сетевое взаимодействие в реальном времени). Технически можно было бы это обойти, но это добавило бы сложности.
- Прокси, у которых слишком много ограничений по cross-origin запросам, заголовкам или ServiceWorkers.
Ключевые выводы
Веб-прокси очень небезопасны
Это middleware, который:
- переписывает HTML
- переписывает JavaScript
- иногда инжектит ServiceWorker
- и часто зависит от параметров запроса / URL-кодирования для маршрутизации запросов
- может вмешиваться в ваши страницы самыми разными способами
Я был удивлён, насколько глубоко лезут некоторые прокси: они переписывают строки исходного кода шейдеров, комментарии и Бог знает что ещё.
Иногда лучшее решение архитектурное
Патч WebGL заставил игры снова рендериться, но удаление мульти-доменной CDN-стратегии сделало поддержку прокси стабильной.
Хорошее напоминание: умные оптимизации могут быть совершенно разумными, пока не столкнутся с враждебным middleware. Или с расширениями браузера пользователя. Или с Safari. Или с языковыми настройками. Или с функциями доступности. Или со вспышками на солнце. Или с чем угодно, в общем.
Заключение
Foony теперь работает за прокси, которые имеют значение (croxyproxy и proxyorb), без превращения кодовой базы в proxy-специфичное месиво:
- Универсальное исправление шейдера Three.js (без proxy-специфичных идентификаторов).
- Более простая стратегия доменов (foony.com везде).