Ізоляція коду студентів, або Складний шлях до безпечного запуску чужого коду
Всім привіт! Останні чотири роки я працюю в Edtech-стартапі Mate academy. Ми допомагаємо вивчати програмування та знаходити першу роботу в ІТ. Щоб наші студенти навчились писати код, треба, щоб вони писали код. Що логічно 🙂
Тому ми розробили для них просунуту LMS-платформу, де вони і вчаться його писати. Цей блог буде саме про цю LMS-платформу. А точніше про мій досвід її написання з безпечним запуском коду, якому, як виявилось в процесі, не можна довіряти.
Також розповім про рішення як на стороні застосунку, так й інфраструктурні, які можуть стати корисними в подібних ситуаціях, як наша. Запуск чужого коду — не щоденна задача, але вона виникає, а інформації про цю тему не так багато. Тому сподіваюсь, що цей матеріал стане в пригоді тим, хто стикнеться зі схожими потребами. Також на власних помилках укотре нагадаю, що безпека завжди повинна бути на першому місці.
Дисклеймер: в англомовній літературі є термін untrusted code. Прямий переклад «код, якому не можна довіряти». Це як «Той, що біжить по лезу», бо не буває у нас того дієприслівника. Але так писати — дуже довго, тому в тексті використовую «чужий код», хоч воно мені теж не дуже подобається.
З чого все починалось
Зараз на нашій платформі реалізовано десятки корисних функцій, які допомагають студентам не тільки вивчати програмування, а й залишатись вмотивованими та комунікувати з іншими. Та починалось все з простого маленького MVP. У далекому
Звісно треба зберігати результати в базі даних і валідувати код на сервері, щоб не було можливості «обманути» систему. На щастя, я одразу подумав, що запускати код, який написали студенти безпосередньо в нашому моноліті, — погана ідея. Так почався пошук іншого, безпечного, ізольованого запуску коду наших студентів.
Варіанти безпечного запуску небезпечного коду
Перша група варіантів — повністю інфраструктурні рішення
Ми всі довіряємо докеру, віртуальним машинам, Kubernetes і подібним інструментам. Варіантів багато: від запуску dedicated-машин в Kubernetes cluster до Firecracker, Kata containers.
Наприклад, ми могли б зупинитись на Kubernetes варіанті:

Одна з вимог — це UX, близький до реального часу. А інфраструктура потребує багато часу, щоб запуститись.
І хоч є опції, які працюватимуть швидше, ніж просто запускати новий под за кліком на кнопку Run, можна організувати overprovisioning, щоб завжди була вільна машина, яка готова вже взяти задачу в роботу. Але однаково це буде недостатньо швидко, адже для користувача все має відбуватись майже миттєво. Та й зусиль на імплементацію і підтримку потрібно немало.
Другий варіант — це sandboxing
Тут все просто: ми запускаємо код в пісочниці та сподіваємося, що ніхто з нього не зможе вийти. Але зробити ідеальний sandbox майже неможливо, і навіть якщо таки вдасться, дуже швидко можна впертись у його обмеження, щоб зрештою запускатись десь окремо.
Як мінімум через те, що якщо ми хочемо таймаут в пʼять секунд на запуск тестів, тобто дозволяємо коду студентів пʼять секунд працювати синхронно — а це не ок, щоб API-сервер чекав стільки. Бо студент може написати while (true) {} і цілий API-под зупиниться і протягом цього часу не буде нічого опрацьовувати.
Третій варіант — гібридний варіант
Оскільки обидва попередні варіанти нам не підійшли, я зупинився на гібридному рішенні: Partial infrastructure isolation with AWS Lambda + Running code in a sandbox + Custom solutions to mitigate sandbox restrictions.
Тобто перш за все — це запуск коду на AWS Lambda.
Lambda functions стали чудовими інструментами для нашої задачі зі створення платформи для студентів, бо за замовчуванням вона має тільки права писати логи, read-only файлову систему та повністю автоматично і необмежено масштабується.
До того ж, це вже якась ізоляція, як мінімум від інших частин платформи. Тобто навіть якщо падає запуск коду — все інше працюватиме. Точніше навіть, якщо, умовно, JS впав, то з Python усе буде ок. Малесенький мікросервіс — чим лямбди, по суті, і є.
Це можна імплементувати й самостійно (як і все зрештою), але для чого, якщо за нас це вже зробили.

Вийшло приблизно ось так: frontend надсилає запит на API endpoint, а якщо бути точним — викликає GraphQL mutation.
Тоді API дістає з бази метадані та викликає Proxy Lambda. Це дуже проста річ, бо єдина її задача — викликати Worker Lambda для відповідної мови, провалідувати результат і обробити помилки.
Але AWS Lambda — не якись магічний інструмент. Це теж сервер, просто його адмініструє AWS, він теж опрацьовує багато запитів від різних користувачів. І вони можуть впливати одне на одного. Тому sandboxing також присутній.
Дуже круто, що Node.js уже має інструмент для запуску коду в ізоляції.
vm — це вбудований в Node.js модуль, який дозволяє запускати код в окремій V8 Virtual machine. Це дає окремий контекст, тобто global object. Але event loop і heap — ті самі.
Ось приклад як це працює:

Маємо код в рядку, передаємо його в метод runInNewContext, разом з ним передаємо контекст, який cпільно з return value буде способом спілкування з кодом, який запускається в sanbox.
Але в коду є можливість використовувати глобальні змінні з основного контексту, якщо ми не передали свої. Це дозволяє нам, наприклад, передати туди пустий об’єкт замість process. Аналогічно можна підмінити require на той, який дозволить імпортувати тільки певні модулі.
Але, на жаль, vm не є безпечним способом запускати чужий код. Sandbox легко можна покинути, навіть якщо закрити всі глобальні змінні, за допомогою прототипів, викидання помилок й інших способів.
На щастя, існує бібліотека vm2, яка додає більше безпеки до vm модуля за допомогою Proxy та інших механізмів. А також дає зручний інтерфейс для налаштування vm. Її я і використав в MVP. А з оновленням Node.js є ще кращий спосіб запускати небезпечний код — isolated-vm. Цей пакет написаний на C++ і може вважатись справді безпечним. Та на час розробки його ще не було.
Як змінювався мій код
Однак мати технологію — ще не означає використовувати її. Перейдімо до коду і того, як він змінювався.
Перш за все, маємо ініціалізацію пісочниці:

Ми створюємо пісочницю (на другому рядку) з потрібним нам контекстом. У цій пісочниці ми можемо запускати код багато разів, і ці запуски можуть змінювати глобальні змінні пісочниці.
Спочатку ми запускаємо код студента. Код — це завжди оголошення функції. Після запуску функція стає доступною глобально. Далі ми продовжуємо працювати з тою самою пісочницею.
Ми не повертаємо vm з функції, а функцію, яка буде використовуватись замість стандартного expect. Далі глянемо, як вона створюється.

Тут ми приймаємо код, який треба запустити, й очікуваний результат vm.run повертає значення, в яке перетворився запущений вираз. Далі — стандартний expect. Тут код, який ми запускаємо — це вже не код студента, а код автора задачі. У ньому зазвичай запускається імплементована студентом функція. Це відбувається в пісочниці, а результат повертаємо в основний контекст, де робимо звичайну для Unit-тестів перевірку. І, власне, сам тест:

У before блоці створюємо функцію, яка запускатиме код в sandbox та порівнюватиме його з результатом. А в кожному тесті використовуємо наш кастомний expect.
Це хороше рішення, яке чудово працювало для близько 50 задач, але... ніхто не хоче таким користуватись. Чому? Бо Unit-тести на код мають виглядати як Unit-тести. Без незрозумілих initVM та safeExpect.
Писати JS-код у рядку дуже незручно, якщо це щось трохи складніше, ніж функція суми. Також ми вирішили показувати код тестів студентам, а такий код точно не те, що допоможе їм зрозуміти, де помилка.
Тому сильним вольовим рішенням я видалив використання vm і почав просто запускати звичайні Unit-тести в лямбді. Усі тести до задач з кучерявим кодом вище були переписані на звичні й зрозумілі всім Unit-тести. І незадовго почалось веселе життя.
Якщо запускати студентський код не в sandbox, такий тривіальний код, як обнулення прототипу, робить життя цікавим і веселим.
Array.prototype = null — це тільки один варіант, і насправді способів все зламати багато. Тоді інстанс лямбди має невалідний JS runtime, і це триває, поки він не стане «холодним» і AWS його не покладе. А це 15 хвилин.
Спочатку це було майже непомітно. Хтось зі студентів зламав, ми трохи не запускались, і все виправлялось (проскочило хіба кілька помилок). Але з масштабуванням і ростом кількості користувачів та задач лямбда перестала ставати холодною взагалі.
Тому в цій ситуації головне завжди мати ноутбук з собою, щоб «передеплоїти лямбду». Достатньо змінити якісь параметри, і AWS підніме новий інстанс.
Геніальний костиль, або Як я вирішив піти кудись без ноуту
Однієї прекрасної суботи, коли я гуляв в Lavina Mall, лямбда зламалась і після перезапуску ламалась майже одразу знову. А я ще й був без ноута (можете собі уявити, який я оптиміст).
Тоді мій колега Вадим Ільченко придумав геніальний костиль. Я думаю, це абсолютний чемпіон зі всіх костилів. Пам’ятаєте проксі лямбду, яка була на початку?

Якщо воркер падає з помилкою — проксі лямбда її ловить, змінює опис лямбди або таймаут чи майже будь-який параметр ресурсу в AWS. Піднімається свіжий інстанс, і все знову працює.
Це, звісно, класно, але з таким довго жити не хотілось, і стало зрозуміло, що потрібно назад повертати пісочницю.

Нарешті рішення
Проблема попереднього рішення з vm була в тому, що sandbox був всередині тесту. А для UX потрібно, щоб тести були повністю у vm . Та проблема в тому, що як mocha , так і jest всередині vm не запускаються. Вони використовують process , os модуль і інші речі, які заборонені в пісочниці. Інакше — ні про яку безпеку мова не йтиме.
Жити я спокійно з такою ситуацією не міг, тому за вихідні написав свій тест-ранер. Не знаю чому, але я навіть не заглядав, як працюють наявні рішення, просто глянув на describe , it і подумав: «Як воно може працювати?».
І, насправді, все доволі просто. Усі глобальні функції з mocha просто беруть і складають колбеки в масив. Як ви могли помітити, в тестах ми одразу запускаємо describe , before та it, але безпосередньо не запускаємо власні колбеки. Це перша фаза, коли читаємо файл.
Це круте рішення, хто б його коли не придумав, бо ми можемо прочитати багато файлів і тоді запускати тести, можемо по одному. І це дуже класно лягає на vm sandbox — запустили там код тесту, і в глобальній змінній уже є масив колбеків. А другим етапом запускаємо їх в try/catch і складаємо кудись результати.
Ось вже більш-менш знайомий код створення пісочниці, тільки з додатковими рівнями абстракції. Тут ми створюємо sandbox, передаючи туди глобальні функції тест-ранера (before, it, describe, etc.) в змінній hooks. А також функцію run, яка запустить тести. І вона, і hooks мають у замиканні структуру даних з колбеками, які потрібно виконати, щоб запустити тести.

Після створення пісочниці виконуємо в ній кожен файл з тестами. Тест імпортує код студента і зберігає функцію в замиканні.

Глобальні хуки додають колбеки в деревовидну структуру даних, яка відповідає структурі тестів.
Частина, яка пропущена, щоб не робити приклади більш громіздкими — це EventEmitter, який використовується, щоб сповіщати про результати виконання тестів та інші події життєвого циклу. Ось такий тестовий файл

перетвориться в аналогічну структуру даних:

Зрештою весь код тестів виконується в пісочниці, а зовні ми отримуємо результати.

І цим кодом я дуже гордий.
На самописному тест-ранері правильно запустились тести приблизно 150 наявних тоді задач, без змін в тести чи еталонні рішення. Пощастило, що я вже зробив до того систему валідації задач для студентів.
Початковий код мав фейлити тести, а еталонне рішення — проходити. Відповідно всі рішення задач були, достатньо було все це прогнати, підмінивши ранер. Минуло пʼять років, і сьогодні це рішення продовжує чудово справлятись зі своєю задачею, а наша LMS-платформа є однією з причини, чому студенти приходять вчитись програмуванню та писати код саме до нас.

Висновки
- Node.js має чудові бібліотеки для ізоляції коду. І якщо виникне схожа задача — є чим скористатись.
- Завжди варто думати про користувачів, навіть якщо це адміністратори (які пишуть задачі). Робочого рішення недостатньо. Воно має бути зручним в користуванні.
- Буває, що з’являється бажання зрізати кути і замість правильного рішення зробити швидке, яке нехтує безпекою чи має інші вади. Не варто 🙂 Це тільки додасть проблем в найближчому майбутньому.
Сподобалась стаття? Підписуйтесь на автора, щоб отримувати сповіщення про нові публікації на пошту.

17 коментарів
Додати коментар Підписатись на коментаріВідписатись від коментарів