Как стать автором
Поиск
Написать публикацию
Обновить

Песочница своими руками

Уровень сложностиСредний
Время на прочтение5 мин
Количество просмотров1.4K

Если бы меня попросили составить список наиболее часто используемых не по делу страшилок в нашей профессии, я бы на первое место с большим отрывом поставил мантру «никогда, ни при каких обстоятельствах, не используйте eval». Потому что это небезопасно, а-та-та. (Для скучающих я привел внизу пополняемый список ненавистных мне идиотских обобщений, рассчитанных на новичков и тупых.)

Как все без исключения догмы, эта тоже имеет крайне ограниченную область применения. Из того, что не нужно разрешать случайным посетителям сайта выполнять любой код на сервере — никак не следует, что eval — чем-то плох per se. В конце концов, зачем-то его ведь придумали и добавили в такую прорву самых разных языков.

Отчасти это поверие связано с тем, что авторы множества языков не озаботились средствами для работы с AST на уровне компилятора, и чтобы не допустить случайную ошибку и не взорвать исполнением кода по месту — приложение вместе с сервером и датацентром — приходится написать свою неспецифицированную, глючную и медленную реализацию половины компилятора. Отчасти — тем, что некоторые языки плохо (или никак) защищены от всякого рода инъекций, и проще всего вылечить перхоть — усекновением главы. На оставшиеся 98% — это просто религиозный запрет, наподобие отказа от свинины.

Вспоминается случай, как в какой-то из своих библиотек на руби я переписал class_eval для плагина — на prepend’ах, когда они появились, просто потому, что я устал закрывать issues в гитхабе, указывающие на небезопасность подхода (хотя плагин мог добавить только автор кода, использующего мою библиотеку, имеющий, в принципе, полную власть над кодом, вплоть до вызова `rm -rf /`, если ему втемяшится всё сломать).

Поэтому когда нам потребовалось разрешить клиентам вводить и исполнять собственные арифметические формулы (что-то типа счетных полей, заданных пользователем) — я знал, что решение на эвалах мои коллеги не поймут. Писать свой «калькулятор», или взять готовый, — мне показалось плохой идеей, потому что завтра пользователи захотят использовать модули, послезавтра — целочисленное деление, а через год — арксинусы. Я не отношусь к категории людей, которым нравится развивать свои библиотеки вечно; я стараюсь сразу написать их так, чтобы в будущем (после фиксации версии 1.0.0) — только проверять работоспособность на новых версиях компилятора (за меня это делают Github Actions).

В общем, мне явно была нужна песочница для вызова пользовательского кода, и я принялся думать, как бы мне её реализовать в эликсире. Для нетерпеливых — вот библиотека Formulae.

Мертворожденные варианты

Первой посетившей меня мыслью, разумеется, была блистательная идея запускать новую ноду для каждого пользователя в изолированном контейнере. Я спросил у девопсов, во сколько нам это обойдется, и еле ушел оттуда целым и невредимым.

Потом я стал ковыряться с изолированными процессами, но OTP писали во времена, когда ножей не знали, и говядину целиком ели, поэтому внутри кластера — почти как в Лас Вегасе — все друг другу доверяют и сообщение можно отправить куда угодно. Я поигрался с собственной реализацией :gen_server, перехватом исходящих сообщений, кастрацией импортов и прочим неликвидом и понял, что кроме седых волос мне этот подход не принесет ничего.

Тогда я попробовал валидировать «формулы», приходящие от пользователей, но не преуспел и тут, потому что оставалось неясным, как сопрячь эту валидацию с фактически неограниченными потенциально возможностями, которых я все-таки не хотел лишать продвинутых пользователей, не представляющих свою жизнь без арктангенсов и гамильтонианов.

И тут меня осенило.

Модуль — как капсула, ограничивающая вызовы стороннего кода

Эликсир (вслед за эрлангом) организует код при помощи модулей. Из функции модуля можно вызвать только функции, определенные в том же модуле, функции, явно импортированные из других модулей, функции модуля Kernel и remote fully-qualified functions (я не знаю, как это перевести понятно, имеется в виду вызов с явным указанием модуля, типа System.cmd("ls")).

Поэтому, если мы явно проконтролируем импорты, запретим вызов небезопасных функций из Kernel, и будем выполнять пользовательский код в функции модуля, мы получим ровно ту песочницу, которая нам нужна.

Останется лишь скомпилировать модуль для пользователя, сообразно установленным правилам, и — voilà — песочница готова. Изнутри модуля не получить доступ ни к чему, кроме напрямую разрешенных вызовов. Удаленные (remote, не removed) вызовы очень просто запретить пройдясь по AST формулы, в Kernel всего пять или типа того небезопасных вызовов, которые можно занести в черный список руками.

Осталось прояснить несколько архитектурных вопросов, и можно приступать к написанию кода.

Оставшиеся неясности

Во-первых, надо бы переиспользовать скомпилированные модули для разных пользователей: компиляция — не самое дешевое действие, а в рамках засилия контейнеров мы уже не можем надеяться на годы в качестве времени жизни нашего сервиса, то есть при каждом рестарте — нам придется компилировать формулы заново. Я уж было затеялся с глобальным списком скомпилированных формул и соответствующих им модулей, но тут вспомнил, что никто, лучше компилятора, не знает, был модуль уже скомпилирован, или нет. Поэтому достаточно просто назвать модуль в честь формулы, и все остальное за нас сделает сам компилятор. Мы убиваем сразу двух зайцев: не нужно генерировать имя модуля, и не нужно проверять, существует ли уже нужный модуль. Итак, для условной формулы a > 42, мы скомпилируем модуль по имени :"Elixir.Formulae.a > 42". Да, так можно: это атом, а единственное ограничение на имена модулей в эликсире и эрланге — имя должно быть атомом.

Далее, я хотел дать возможность на серверной стороне разрешать сложные вызовы функций из других модулей, поэтому при генерации необходимо получить и обработать белый список специально дозволенных вызовов. Здесь я пошел по пути наименьшего сопротивления: я просто импортирую эксплицитно разрешенные функции, чтобы не возиться с проверкой удалённых вызовов (они по-прежнему запрещены). Если пользователю нужна функция pi/0 из модуля :math, он может сделать что-то типа такого:

f = Formulae.compile("pi() + 2", imports: [{:math, pi: 0}])
assert Formulae.eval(f, []) > 5

При этом сами формулы приходят от пользователя, а что кому разрешать импортировать — решает администратор сервиса.

Остались малозначительные штрихи: максимально подробный экспорт информации о формуле, каррирование, и (мой личный фетиш) — переиспользование формул в виду гардов, когда возможно.

А еще, естественно, мне захотелось облегчить написание кода с использованием этой библиотеки, поэтому она поддерживает сигил ~F[…].

Лучше один раз увидеть

Вот итоговый вариант после компиляции:

iex|🌢|1 ▶ import Formulae.Sigils
Formulae.Sigils

iex|🌢|2 ▶ f = ~F[x / y > 42]
#ℱ<[
  sigil: "~F[x / y > 42]",
  eval: &:"Elixir.Formulae.x ÷ y > 42".eval/1,
  formula: "x / y > 42",
  guard: nil,
  module: :"Elixir.Formulae.x ÷ y > 42",
  variables: [:x, :y],
  options: […]
]>

iex|🌢|3 ▶ f.eval.(x: 43, y: 1)
true
iex|🌢|4 ▶ f.eval.(x: 42, y: 1)
false

Подробнее — в документации. Можете попробовать эту песочницу взломать — я как Кнут, выплачиваю по :math.pow(:math.pi(), n) евро за каждую уязвимость, где n — порядковый номер уязвимости.

Удачного формулирования!


P. S. Неполный список нелепых догм (ака хороших практик)

    ① eval  — несомненное зло
    ② длина функции/метода не должна превышать три (пять, десять) строк кода
    ③ чужая библиотека всегда лучше своего велосипеда
    ④ существует парадигма, в которой удобнее решать любые задачи
    ⑤ недопустимо использование в коде символов, выходящих за пределы ASCII-7
    ⑥ для выделения абстракции требуется как минимум два (три, пять) случаев копипасты
    ⑦ хорошая среда разработки может помочь с рефакторингом
    ⑧ чем выше покрытие тестами — тем надежнее код
    ⑨ статическая типизация всегда помогает
    ⑩ читаемость кода является показателем его качества

Теги:
Хабы:
Всего голосов 13: ↑13 и ↓0+19
Комментарии17

Публикации

Ближайшие события