background blurbackground mobile blur

1/1/1970

چطور کاری کردم که Foony پشت پروکسی‌ها هم کار کند

سلام! خیلی وقت است می‌دانم وب‌پروکسی‌ها برای وب‌سایت‌ها مشکل سازگاری درست می‌کنند. با این حال، پشتیبانی Foony پشت پروکسی‌ها همیشه افتضاح بوده و درست کردن سازگاری Foony با پروکسی‌ها حسابی دردسر داشت.

ماجرا از این جنس هم نبود که «Foony از APIهای عجیب‌وغریب استفاده می‌کند» (هرچند واقعاً همین کار را می‌کنیم). مشکل ترکیبی بود از:

  • پروکسی‌هایی که هر جا دلشان می‌خواهد رشته‌ها را شدیداً بازنویسی می‌کنند، جاهایی که اصلاً نباید دست بزنند.
  • پروکسی‌هایی که دامنه اصلی سایت را با دامنه‌های «دیگر» (CDNها، هاست فایل‌ها و غیره) متفاوت رفتار می‌کنند.
  • و واقعیت تلخ این که بعضی پروکسی‌ها کلاً از پس وب‌اپ‌های مدرن برنمی‌آیند (رعایت درست HTTPS، WebSocketها و چیزهای دیگر).

Foony هنوز روی همه‌ی پروکسی‌ها کار نمی‌کند، ولی الان دست‌کم با 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;

بعضی پروکسی‌ها هر چیزی را که شبیه API جاوااسکریپتی location باشد، با یک جایگزینی رشته‌ای سراسری و خیلی ساده‌لوحانه عوض می‌کنند. این خودش توی JS هم کار بدی است، ولی مشکل اینجا بود که همین کار را داخل رشته‌های سورس شیدر هم انجام می‌دادند. حدس می‌زنم پارس کردن AST برایشان خیلی گران تمام می‌شود.

در نتیجه سورس شیدر به چیزی شبیه این خراب می‌شد:

layout(__cpLocation = 0) in vec3 position;

در آنجا شناسه حتماً باید location باشد. هر چیز دیگری در GLSL نامعتبر است و کامپایلر ردش می‌کند. (Layout Qualifiers in GLSL)

این فقط از این نظر به Three.js ربط دارد که Three.js شیدرها را به شکل داینامیک می‌سازد و ما هم آن‌ها را در زمان اجرا به WebGL می‌دهیم. باگ واقعی توی استراتژی بازنویسی خود پروکسی است.

چرا خودِ «پروکسی را درست» نکردم

یک راه ساده‌لوحانه این بود که دنبال رشته‌ی جایگزین croxyproxy برای location یعنی __cpLocation بگردم و دوباره آن را به location برگردانم. اما پروکسی‌های مختلف از نام‌های جایگزین متفاوتی استفاده می‌کنند. بعضی‌ها __cpLocation دارند، بعضی‌های دیگر شناسه‌های عجیب‌وغریب دیگری. پس این که به‌صورت هاردکد بگویم «__cpLocation را دوباره به location تبدیل کن» خیلی شکننده است.

من به این چیزها احتیاج داشتم:

  • یک راه‌حل کلی (بدون هاردکد کردن شناسه‌های مخصوص هر پروکسی).
  • راه‌حلی که حتی اگر پروکسی خودِ کلمه‌ی location را هم داخل جاوااسکریپتم بازنویسی کند، باز هم کار کند.

حقه‌ی 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 یا نوشتن هگز window['\x6c\x6f\x63\x61\x74\x69\x6f\x6e'] هم احتمالاً جواب می‌دهد.

الگوی شیدر خراب‌شده از نظر ساختاری همیشه همین بود:

layout(<something> = <number>)

پس به‌صورت کلی همین الگو را پیدا می‌کنم و <something> را با شناسه‌ی درست جایگزین می‌کنم:

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 خودشان را رجیستر می‌کنند و از این جور کارها.

اما وقتی اپ شروع می‌کند از یک اوریجین دیگر (مثلاً 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 وابسته است و خیلی هم شکننده‌اند.

یکی از مثال‌های واضح، یک آدرس فایل بود شبیه این:

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

آن ?__pot=... در واقع اطلاعات روتینگ / وضعیت خود پروکسی است که بهش می‌گوید این درخواست مربوط به کدام دامنه است. اگر آن را حذف کنی، پروکسی دیگر نمی‌تواند درخواست را درست حل کند و سر از همان خطاهای ServiceWorker در می‌آوری.

«تعویض منبع» به‌عنوان نجات‌دهنده (و این که چرا خیلی زود پیچیده شد)

در یک مقطع، یک راه‌حل موقتی امتحان کردم: تشخیص بدهم «ما پشت پروکسی هستیم»، بعد هر URL مربوط به منابع foony.io را با اوریجین فعلی عوض کنم تا پروکسی همه‌چیز را هم‌اُریجین ببیند.

روی کاغذ منطقی به نظر می‌رسید و روی croxyproxy هم جواب داد، اما کلی پیچیدگی اضافه کرد:

  • باید تگ‌های link و scriptای را که از قبل داخل HTML وجود دارند عوض می‌کردم.
  • باید یک MutationObserver می‌گذاشتم تا تگ‌هایی را که بعداً داینامیک تزریق می‌شوند (modulepreload، stylesheet و غیره) هم پوشش دهد.
  • باید کوئری‌پارام‌های پروکسی را هم حفظ می‌کردم، وگرنه روتینگ آن‌ها می‌شکست. و طبیعتاً هر پروکسی هم این کار را به روش خودش انجام می‌دهد. معلوم است که همین‌طور باشد!
  • و با وجود همه‌ی این‌ها، باید منطق را همچنان عمومی نگه می‌داشتم (بدون متغیرهای سراسری مخصوص هر پروکسی) تا کد تبدیل به یک توده‌ی متورم و به‌هم‌ریخته نشود.

اینجا همان جایی بود که دوباره «حقه‌ی base64» سر و کله‌اش پیدا شد: حتی داخل جاوااسکریپت خودم هم باید حواسم به رشته‌ی 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 نسبتاً راحت می‌شود آن را برعکس کرد.

نتیجه‌اش جاوااسکریپتی است که خیلی خواندنی‌تر است:

((_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، در برابر صرفه‌جویی کوچک شبکه‌ای درخواست‌های تمیز و بدون کوکی، اصلاً نمی‌ارزد. از وقتی foony.io را اضافه کرده بودیم، یک افت عجیب در نرخ شروع بازی‌ها می‌دیدیم، برای همین حدس می‌زنم مشکلات دیگری هم با foony.io داشتیم که خبر نداشتیم.

پس foony.io را حذف کردم. حداقل فعلاً.

به محض این که منطق CDN مربوط به foony.io را پاک کردم و همه‌چیز را روی foony.com متمرکز کردم، پشتیبانی از پروکسی‌ها به‌شدت ساده‌تر شد:

  • لود شدن فایل‌ها از همان اوریجین.
  • موردهای خاص کمتر برای توضیح دادن به ServiceWorker پروکسی.
  • بازنویسی کمتر.
  • کد کم‌ریسک‌تر و محکم‌تر.

خلاصه این که حذف کردن foony.io یک ساده‌سازی معماری بود که سطح تماس با رفتارهای عجیب پروکسی‌ها را کم کرد.


مرحله ۳: چه چیزهایی کار می‌کند، چه چیزهایی نه و چرا

پروکسی‌هایی که کار کردن‌شان را تأیید کرده‌ام

در حال حاضر Foony پشت این پروکسی‌ها کار می‌کند:

  • croxyproxy
  • proxyorb

احتمالاً چند پروکسی دیگر هم کار می‌کنند. حدس می‌زنم بیشترشان هنوز مشکل دارند. اما دست‌کم آن‌هایی که مردم واقعاً برای بازی کردن استفاده می‌کنند به نظر می‌رسد درست کار می‌کنند.

چرا نه «همه‌ی پروکسی‌ها»؟

بعضی پروکسی‌ها ذاتاً توان پشتیبانی از یک وب‌اپ چندنفره‌ی مدرن را ندارند. مثلاً:

  • پروکسی‌هایی که HTTPS را درست پشتیبانی نمی‌کنند.
  • پروکسی‌هایی که WebSocketها را می‌شکنند یا مسدود می‌کنند (Foony از شبکه‌ی لحظه‌ای استفاده می‌کند). از نظر فنی می‌شود راه‌حلی دورتادور این مشکل پیدا کرد، اما باز هم پیچیدگی اضافه می‌کند.
  • پروکسی‌هایی که روی درخواست‌های cross-origin، هدرها یا ServiceWorkerها محدودیت‌های زیادی می‌گذارند.

نکته‌های اصلی

وب‌پروکسی‌ها خیلی ناامن‌اند

آن‌ها یک واسط در وسط راه هستند که:

  • HTML را بازنویسی می‌کنند
  • جاوااسکریپت را بازنویسی می‌کنند
  • گاهی ServiceWorker مخصوص خودشان را تزریق می‌کنند
  • و معمولاً برای روتینگ درخواست‌ها به کوئری‌پارام‌ها و شیوه‌ی انکد کردن URL وابسته‌اند
  • و می‌توانند به هر شکل ممکنی با صفحات شما ور بروند

برایم جالب بود که بعضی پروکسی‌ها تا چه عمقی پیش می‌روند: آن‌ها حتی رشته‌های سورس شیدرها، کامنت‌ها و خدا می‌داند چه چیزهای دیگری را بازنویسی می‌کنند.

گاهی بهترین راه‌حل، معماری است

پچ WebGL باعث شد بازی‌ها دوباره رندر شوند، اما کنار گذاشتن استراتژی CDN چنددامنه‌ای بود که باعث شد پشتیبانی از پروکسی به‌طور پایدار درست بماند.

این یک یادآوری خوب است: بهینه‌سازی‌های باهوشانه تا وقتی منطقی‌اند که به یک واسط بدجنس وسط راه نخورند. یا افزونه‌های مرورگر کاربر. یا Safari. یا تنظیمات زبان. یا قابلیت‌های دسترس‌پذیری. یا شراره‌های خورشیدی. یا هر چیز دیگری، واقعاً.


جمع‌بندی

الان Foony پشت آن پروکسی‌هایی که مهم‌اند (croxyproxy و proxyorb) کار می‌کند، بدون این که کدبیس را به یک آشفته‌بازار مخصوص پروکسی‌ها تبدیل کند:

  • یک راه‌حل کلی برای شیدرهای Three.js (بدون شناسه‌های مخصوص پروکسی).
  • یک استراتژی ساده‌تر برای دامنه (همه‌جا foony.com).
8 Ball Pool online multiplayer billiards icon