Улучшенный sandboxing для Groovy скриптов

Автор оригинала: Cédric Champeau‏
  • Перевод


От переводчика: При разработке CUBA Platform мы заложили в этот фреймворк возможность исполнения пользовательских скриптов для более гибкой настройки бизнес-логики приложений. О том, хороша или плоха эта возможность (и мы говорим не только о CUBA), ведутся долгие споры, но то, что контроль исполнения пользовательских сценариев необходим — это ни у кого не вызывает вопросов. Одна из полезных возможностей Groovy для управления исполнением пользовательских скриптов представлена в этом переводе статьи Cédric Champeau. Несмотря на то, что он недавно‏ покинул команду разработки Groovy, сообщество программистов, по видимому, еще долгое время будем пользоваться плодами его трудов.


Один из наиболее часто используемых способов использования Groovy — это скриптинг, поскольку Groovy позволяет легко исполнять код динамически, в рантайме. В зависимости от приложения, скрипты могут находиться в различных местах: файловой системе, БД, удаленных сервисах… но самое важное — разработчик приложения, исполняющего скрипты, не обязательно сам их пишет. Более того, скрипты могут работать в ограниченном окружении (ограниченный объем памяти, лимит на количество дескрипторов файлов, время исполнения…), или вы можете захотеть запретить пользователю использовать все возможности языка в скрипте.


Этот пост вам расскажет


  • почему Groovy хорошо подходит для написания внутренних DSL
  • каковы его возможности в плане безопасности вашего приложения
  • как настроить компиляцию для улучшения DSL
  • о значении SecureASTCustomizer
  • о расширениях для контроля типов
  • как использовать расширения для контроля типов, чтобы sandboxing был эффективным

Например, представьте себе, что вам нужно сделать так, чтобы пользователь мог вычислять математические выражения. Один из вариантов реализации — встроить внутренний DSL, создать парсер и, наконец, интерпретатор для этих выражений. Для этого, понятное дело, придется поработать, но если вам нужно повысить производительность, например, с помощью генерации байт-кода для выражений вместо их вычисления в интерпретаторе или использовать кэширование классов, генерируемых в рантайме, тогда Groovy — отличный вариант.


Есть множество вариантов, описанных в документации, но самый простой пример — это просто использование класса Eval:


Example.java


int sum = (Integer) Eval.me("1+1");

Код 1+1 парсится, компилируется в байт-код, загружается и исполняется Groovy в рантайме. Конечно, в этом образце код очень простой, и вам понадобится добавить параметры, но идея в том, что исполняемый код может быть произвольным. И это, возможно, не совсем то, что вам нужно. В калькуляторе вам нужно разрешать примерно такие выражения:


1+1
x+y
1+(2*x)**y
cos(alpha)*r
v=1+x

но уж точно не


println 'Hello'
(0..100).each { println 'Blah' }
Pong p = new Pong()
println(new File('/etc/passwd').text)
System.exit(-1)
Eval.me('System.exit(-1)') // a script within a script!

Именно здесь и начинаются трудности, а также становится понятно, что нам нужно решить несколько задач:


  • ограничить грамматику языка до некоторого подмножества его возможностей
  • предотвратить исполнение не предусмотренного кода пользователями
  • предотвратить исполнение вредоносного кода

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


Несколько лет назад я был в такой ситуации. Я разработал движок, который исполнял “сценарии” Groovy, написанные лингвистами. Одной из проблем, например, было то, что они могли непреднамеренно создать бесконечный цикл. Код исполнялся на сервере, и там появлялся поток, пожирающий 100% CPU, после чего приходилось перезапускать сервер приложений. Пришлось искать способ решить проблему, не затрагивая DSL, инструменты или производительность приложения.


На самом деле, у многих людей схожие потребности. За последние 4 года я разговаривал с множеством людей, у которых был один и тот же вопрос: Как сделать, чтобы пользователи не могли натворить ерунды в скриптах Groovy?


Кастомайзеры компиляции


На тот момент у меня уже было свое решение и я знал, что другие люди тоже разработали что-то похожее. В конце концов, Гийом Лафорж (Guillaume Laforge) предложил мне создать в ядре Groovy механизм, который поможет решить эти проблемы. Он появился в Groovy 1.8.0 в виде кастомайзеров компиляции.


Кастомайзеры компиляции — это набор классов, которые модифицируют процесс компиляции скриптов Groovy. Вы можете написать и свой кастомайзер, но Groovy поставляет:


  • кастомайзер импорта, неявно добавляющий импорты в скрипты, чтобы пользователям не нужно было добавлять описания "import"
  • кастомайзер AST (Abstract Syntax Tree)-трансформаций, позволяющий добавлять AST-трансформации непосредственно в скрипты
  • Secure AST customizer, ограничивающий грамматические и синтаксические конструкции языка

Кастомайзер AST-трансформаций помог мне решить проблему бесконечного цикла с помощью трансформации @ThreadInterrupt, а вот SecureASTCustomizer — это та вещь, которую, возможно, больше всего неверно понимают в подавляющем большинстве случаев.


Мне стоит извиниться за это. Тогда я не смог придумать лучшего названия. Самая важная часть в названии “SecureASTCustomizer” — это AST. Целью создания этого механизма было ограничение доступа к некоторым функциям AST. Слово "secure" в названии вообще лишнее, и я объясню почему. Есть даже пост в блоге всем известного по Jenkins Косукэ Кавагути, под названием “Губительный Groovy SecureASTCustomizer”. И там все очень правильно написано. SecureASTCustomizer создавался без расчета на sandboxing. Он был создан для ограничения языка во время компиляции, но не исполнения. Сейчас я думаю, что лучшим названием было бы GrammarCustomizer. Но, как вам безусловно известно, в информатике есть три трудности: инвалидация кэша, придумывание имен и ошибка на единицу.


А теперь представьте себе, что вы рассматриваете secure AST customizer как средство для обеспечения безопасности вашего скрипта, и ваша задача — не позволить пользователю вызвать System.exit из скрипта. В документации сказано, что вызовы можно запретить в специальных ресиверах, создавая черные или белые списки. Если нужна безопасность, я всегда рекомендую белые списки, строго оговаривающие, что разрешено, но не черные списки, запрещающие что-либо. Потому что хакеры всегда думают о том, чего вы могли не учесть. Приведу пример.


Вот как можно настроить примитивный движок "sandbox"-скрипта с помощью SecureASTCustomizer. Хотя мог бы написать их на Groovy, я привожу примеры конфигурации на Java, чтобы разница между кодом интеграции и скриптами была более явной.


public class Sandbox {
    public static void main(String[] args)  {
        CompilerConfiguration conf = new CompilerConfiguration();
        SecureASTCustomizer customizer = new SecureASTCustomizer();
        customizer.setReceiversBlackList(Arrays.asList(System.class.getName()));
        conf.addCompilationCustomizers(customizer);
        GroovyShell shell = new GroovyShell(conf);
        Object v = shell.evaluate("System.exit(-1)");
        System.out.println("Result = " +v);
    }
}

  1. создаем конфигурацию компилятора
  2. создаем secure AST customizer
  3. объявляем, что класс System как получатель вызовов метода находится в черном списке
  4. добавляем кастомайзер в конфигурацию компилятора
  5. связываем конфигурацию с командным интерпретатором скрипта, то есть пытаемся создать sandbox
  6. запускаем “плохой” скрипт
  7. выводим на экран результат запуска скрипта

Если вы запустите этот класс, то во время выполнения скрипта вылетит ошибка:


General error during canonicalization: Method calls not allowed on [java.lang.System]
java.lang.SecurityException: Method calls not allowed on [java.lang.System]

Такой вывод выдает приложение с secure AST-кастомайзером, который не дает выполнить методы класса System. Успех! Вот мы и защитили наш скрипт! Но постойте-ка…


SecureASTCustomizer взломан!


Защита, говорите? А что если я сделаю так:


def c = System
c.exit(-1)

Если запустите программу еще раз, то увидите, что что она вылетает без ошибки и без вывода результата на экран. Код завершения процесса -1, что означает, что пользовательский скрипт был запущен! Что произошло? Во время компиляции secure AST customizer не способен распознать, что c.exit — вызов метода System в принципе, потому что он работает на уровне AST! Он анализирует вызов метода, а в данном случае вызов метода — это c.exit(-1), затем определяет ресивер и проверяет, находится ли тот в белом (или черном) списке. В данном случае ресивером является c, эта переменная объявлена через def, а это все равно что объявить ее как Object, и secure AST customizer подумает, что тип переменной c — это Object, а не System!


Вообще есть множество способов обойти различные конфигурации, созданные на secure AST customizer. Вот несколько прикольных:


((Object)System).exit(-1)
Class.forName('java.lang.System').exit(-1)
('java.lang.System' as Class).exit(-1)

import static java.lang.System.exit
exit(-1)

и их может быть намного больше. Динамическая природа Groovy исключает возможность устранить эти проблемы во время компиляции. Однако решение существует. Один из вариантов — положиться на стандартный диспетчер безопасности JVM. Однако это тяжеловесное и объемное решение сразу для всей системы, и это равносильно стрельбе из пушки по воробьям. К тому же, оно работает не во всех случаях, например, если вы хотите запретить чтение файлов, но не создание…


Это ограничение — для многих из нас скорее огорчение — привело к созданию решения на основе проверок во время исполнения. У этого вида проверок нет таких проблем. Например, потому что вам будет известен фактический тип ресивера сообщения до начала проверки допустимости вызова метода. Особый интерес представляют следующие реализации:



Однако, ни одна из этих реализаций не является абсолютно надежной и безопасной. Например, версия Косукэ основана на хаке внутренней реализации кэширования call site. Проблема в том, что она не совместима с invokedynamic-версией Groovy, и этих внутренних классов не будет в будущих версиях Groovy. Версия Саймона, с другой стороны, основана на AST-трансформациях, но оставляет много возможных дыр.


В итоге, я и мои друзья Коринн Криш, Фабриция Матрат и Себастьян Блан решили создать новый механизм sandboxing’а в рантайме, у которого не будет таких проблем, как у этих проектов. Мы начали реализовывать его на хакатоне в Ницце, а на конференции Greach в прошлом году делали об этом доклад. Этот механизм основан на AST-трансформациях и существенно переписывает код, чтобы производить проверку перед каждым вызовом метода, попыткой доступа к полю класса, приращением переменной, бинарным выражением… Эта реализация все еще не готова, и над ней было проделано не очень много работы, так как я осознал, что проблема с методами и параметрами, вызываемыми через "implicit this", еще не решена, как, например, в билдерах:


xml {
   cars {    // cars is a method call on an implicit this: "this".cars(...)
     car(make:'Renault', model: 'Clio')
   }
}

На сегодняшний день я все еще не нашел способ решить эту проблему из-за архитектуры протокола мета-объектов в Groovy, которая основана на том, что ресивер выбрасывает исключение, когда не может найти метод, до того, как переключиться на другой ресивер. Вкратце, это значит, что вы не можете узнать тип ресивера до фактического вызова метода. А если вызов прошел, то уже слишком поздно…


А ещё до недавнего времени у меня не было оптимального решения этой проблемы для случая, в котором исполняемый скрипт использует динамические свойства языка. Но теперь настало время объяснить, как можно существенно улучшить ситуацию, если вы готовы немного пожертвовать динамичностью языка.


Проверка типов


Вернемся к основной проблеме с SecureASTCustomizer: он работает с абстрактным синтаксическим деревом и не обладает информацией о конкретных типах и ресиверах сообщений. Но с версии Groovy 2 у Groovy появилась дополнительная компиляция, а в Groovy 2.1 мы добавили расширения для проверки типов.


Расширения для проверки типов — очень мощная штука: они позволяют разработчику Groovy DSL помочь компилятору с выводом типов, а также позволяют генерировать ошибки компиляции в случаях, когда они обычно не возникают. Эти расширения используются внутри Groovy для поддержки статического компилятора, например при реализации типажей (traits) или движка шаблонов разметки.


Что если вместо использования результатов работы парсера мы могли бы полагаться на информацию от механизма проверки типов? Возьмем код, который попытался написать наш хакер:


((Object)System).exit(-1)


Если активировать проверки типов, код не скомпилируется:


1 compilation error:

[Static type checking] - Cannot find matching method java.lang.Object#exit(java.lang.Integer). Please check if the declared type is right and if the method exists.

Итак, этот код больше не компилируется. А что если взять такой код:


def c = System
c.exit(-1)

Как вы можете убедиться, он проходит проверку типа, будучи обернутым в метод и выполненным с помощью команды groovy:


@groovy.transform.TypeChecked // or even @CompileStatic
void foo() {
  def c = System
  c.exit(-1)
}
foo()

Type checker обнаруживает, что метод exit вызывается из класса System и является валидным. Здесь это нам не поможет. Но что мы знаем, так это то, что если этот код проходит проверку типов, то это значит, что компилятор распознает вызов на ресивер с типом System. В общем, идея в том, чтобы запретить вызов с помощью расширения для проверки типов.


Простое расширение для проверки типов


Перед тем, как подробно углубиться в sandboxing, попробуем "обезопасить" наш скрипт с помощью обычного расширения для проверки типов. Зарегистрировать такое расширение легко: просто установите параметр extensions для аннотации @TypeChecked (или @CompileStatic, если вы используете статическую компиляцию):


@TypeChecked(extensions=['SecureExtension1.groovy'])
void foo() {
  def c = System
  c.exit(-1)
}
foo()

Поиск расширения будет происходить в classpath’е в формате исходного кода (можно сделать предкомпилированные расширения для проверки типов, но в этой статье мы их не рассматриваем):


SecureExtension1.groovy


onMethodSelection { expr, methodNode ->
   if (methodNode.declaringClass.name=='java.lang.System') {
      addStaticTypeError("Method call is not allowed!", expr)
   }
}

  1. когда type checker выбирает метод для вызова
  2. если метод относится к классу System
  3. то пусть type checker сгенерирует ошибку

Вот и все, что нужно. Теперь снова запустите код, и вы увидите ошибку компиляции!


/home/cchampeau/tmp/securetest.groovy: 6: [Static type checking] - Method call is not allowed!
 @ line 6, column 3.
     c.exit(-1)
     ^
1 error

В этот раз, благодаря type checker’у, c распознается как экземпляр класса System, и мы можем запретить вызов. Это очень простой пример, и он демонстрирует не все, что можно сделать с secure AST customizer в плане конфигурации. В написанном нами расширении проверки захардкожены, но, возможно, было бы лучше сделать их настраиваемыми. Так что давайте сделаем пример посложнее.


Предположим, ваше приложение вычисляет некие показатели для документа и позволяет пользователям их настраивать. В таком случае DSL:


  • будет оперировать (как минимум) переменной score
  • позволит пользователям выполнять математические операции (в том числе вызов методов cos, abs, …)
  • должен запрещать все другие методы

Образец пользовательского скрипта:


abs(cos(1+score))


Такой DSL легко настроить. Это вариант того, что мы определили выше:


Sandbox.java


CompilerConfiguration conf = new CompilerConfiguration();
ImportCustomizer customizer = new ImportCustomizer();
customizer.addStaticStars("java.lang.Math");
conf.addCompilationCustomizers(customizer);
Binding binding = new Binding();
binding.setVariable("score", 2.0d);
GroovyShell shell = new GroovyShell(binding,conf);
Double userScore = (Double) shell.evaluate("abs(cos(1+score))");
System.out.println("userScore = " + userScore);

  1. добавить import customizer, который добавит import static java.lang.Math.* ко всем скриптам
  2. сделать переменную score доступной для скрипта
  3. выполнить скрипт

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


Итак, наш скрипт работает, но ничто не мешает хакеру запустить вредоносный код. Так как мы планируем использовать проверку типов, я бы порекомендовал использовать трансформацию @CompileStatic:


  • она активирует проверку типов в скрипте, и мы сможем выполнять дополнительные проверки благодаря расширению для проверки типов
  • улучшит производительность скрипта

Неявно добавить аннотацию @CompileStatic в скрипты довольно просто. Нужно только обновить конфигурацию компилятора:


ASTTransformationCustomizer astcz = new ASTTransformationCustomizer(CompileStatic.class);
conf.addCompilationCustomizers(astcz);

Теперь если вы снова попытаетесь запустить скрипт, вы увидите ошибку компиляции:


Script1.groovy: 1: [Static type checking] - The variable [score] is undeclared.
 @ line 1, column 11.
   abs(cos(1+score))
             ^

Script1.groovy: 1: [Static type checking] - Cannot find matching method int#plus(java.lang.Object). Please check if the declared type is right and if the method exists.
 @ line 1, column 9.
   abs(cos(1+score))
           ^

2 errors

Что произошло? Если прочесть скрипт с точки зрения компилятора, станет ясно, что он ничего не знает о переменной "score". А вот вы как разработчик знаете, что это переменная типа double, но компилятор не может ее вывести. Именно для этого созданы расширения для проверки типов: вы можете дать компилятору дополнительную информацию, и компиляция пройдет нормально. В данном случае нам нужно указать, что переменная score относится к типу double.


Поэтому можно немного изменить способ, которым добавляется аннотация @CompileStatic:


ASTTransformationCustomizer astcz = new ASTTransformationCustomizer(
        singletonMap("extensions", singletonList("SecureExtension2.groovy")),
        CompileStatic.class);

Это "эмулирует" код, аннотированный @CompileStatic(extensions=['SecureExtension2.groovy']). Теперь нам, конечно, понадобится написать расширение, которое будет распознавать переменную score:


SecureExtension2.groovy


unresolvedVariable { var ->
   if (var.name=='score') {
      return makeDynamic(var, double_TYPE)
   }
}

  1. в случае если type checker не может определить переменную
  2. если имя переменной — score
  3. пусть компилятор определит переменную динамически с типом double

Полное описание DSL расширений для проверки типов можно найти в этом разделе документации, но здесь есть образец комбинированного режима компиляции: компилятор не может определить переменную score. Вы, как разработчик DSL, знаете, что переменная на самом деле есть и ее тип — double, поэтому вызов makeDynamic здесь для того, чтобы сказать: "окей, не волнуйся, я знаю, что делаю, эту переменную можно определить динамически с типом double". Вот и все!


Первое завершенное "secure" расширение


Теперь соберем все воедино. Мы написали одно расширение для проверки типов, которое предотвращает вызовы методов класса System с одной стороны и другое, которое определяет переменную score, с другой. Итак, если мы соединим их, мы получим первое полное расширение для проверки типов:


SecureExtension3.groovy


// disallow calls on System
onMethodSelection { expr, methodNode ->
    if (methodNode.declaringClass.name=='java.lang.System') {
        addStaticTypeError("Method call is not allowed!", expr)
    }
}

// resolve the score variable
unresolvedVariable { var ->
    if (var.name=='score') {
        return makeDynamic(var, double_TYPE)
    }
}

Не забудьте обновить конфигурацию в вашем Java-классе, чтобы использовать новое расширение для проверки типов:


ASTTransformationCustomizer astcz = new ASTTransformationCustomizer(
        singletonMap("extensions", singletonList("SecureExtension3.groovy")),
    CompileStatic.class);

Запустите код снова — он все еще работает. Теперь попробуйте сделать вот что:


abs(cos(1+score))
System.exit(-1)

Компиляция скрипта вылетит с ошибкой:


Script1.groovy: 1: [Static type checking] - Method call is not allowed!
 @ line 1, column 19.
   abs(cos(1+score));System.exit(-1)
                     ^

1 error

Поздравляем, вы только что написали первое расширение для проверки типов, которое предотвращает запуск вредоносного кода!


Улучшение конфигурации расширения


Итак, все идет хорошо, мы можем запрещать вызовы методов класса System, но, похоже, скоро обнаружатся новые уязвимости, и нам понадобится предотвратить запуск вредоносного кода. Так что вместо того, чтобы хардкодить все в расширении, мы попробуем сделать наше расширение универсальным и настраиваемым. Это, наверное, самое сложное, потому что не существует прямого способа передать контекст в расширение для проверки типов. Идея, таким образом, основана на том, чтобы использовать thread local переменную (кривой способ, да) для передачи конфигурационных данных type checker’у.


В первую очередь мы сделаем список переменных настраиваемым. Так будет выглядеть код со стороны Java:


Sandbox.java


public class Sandbox {
    public static final String VAR_TYPES = "sandboxing.variable.types";

    public static final ThreadLocal<Map<String, Object>> COMPILE_OPTIONS = new ThreadLocal<>();

    public static void main(String[] args) {
        CompilerConfiguration conf = new CompilerConfiguration();
        ImportCustomizer customizer = new ImportCustomizer();
        customizer.addStaticStars("java.lang.Math");
        ASTTransformationCustomizer astcz = new ASTTransformationCustomizer(
                singletonMap("extensions", singletonList("SecureExtension4.groovy")),
                CompileStatic.class);
        conf.addCompilationCustomizers(astcz);
        conf.addCompilationCustomizers(customizer);

        Binding binding = new Binding();
        binding.setVariable("score", 2.0d);
        try {
            Map<String,ClassNode> variableTypes = new HashMap<String, ClassNode>();
            variableTypes.put("score", ClassHelper.double_TYPE);
            Map<String,Object> options = new HashMap<String, Object>();
            options.put(VAR_TYPES, variableTypes);
            COMPILE_OPTIONS.set(options);
            GroovyShell shell = new GroovyShell(binding, conf);
            Double userScore = (Double) shell.evaluate("abs(cos(1+score));System.exit(-1)");
            System.out.println("userScore = " + userScore);
        } finally {
            COMPILE_OPTIONS.remove();
        }
    }
}

  1. создаем ThreadLocal, который будет содержать контекстную конфигурацию расширения для проверки типов
  2. обновляем имя расширения — SecureExtension4.groovy
  3. variableTypes — список соответствий “имя переменной → тип переменной”
  4. здесь мы добавляем объявление переменной score
  5. options — это конфигурация проверки типов
  6. добавляем значение "variable types" в конфигурацию для ключа VAR_TYPES
  7. и записываем конфигурацию в thread local
  8. наконец, чтобы избежать утечки данных, нужно удалить конфигурацию из thread local

А вот как расширение для проверки типов может это использовать:


import static Sandbox.*

def typesOfVariables = COMPILE_OPTIONS.get()[VAR_TYPES]

unresolvedVariable { var ->
    if (typesOfVariables[var.name]) {
        return makeDynamic(var, typesOfVariables[var.name])
    }
}

  1. выгрузите список имен переменных с типами из thread local
  2. если переменная не определена в коде, но ее имя есть в списке,
  3. то проинструктируйте type checker объявить эту переменную с заданным типом

Расширение для проверки типов может читать конфигурацию из thread local, потому что оно запускается, когда type checker верифицирует скрипт. Затем, вместо использования захардкоженных имен в unresolvedVariable, мы можем просто проверить, что переменная, которую не опознает type checker, объявлена в конфигурации. Если да, мы можем вытащить ее тип. Это было легко!


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


Конфигурация белого списка методов


Идея белого списка проста. Вызов метода разрешен, если дескриптор метода есть в белом списке. Этот список состоит из регулярных выражений, а дескриптор метода состоит из полного имени класса метода, имени метода и его параметров. Например, для System.exit, дескриптор выглядит так:


java.lang.System#exit(int)

Посмотрим, как обновить интеграцию с Java, чтобы встроить эту конфигурацию:


public class Sandbox {
    public static final String WHITELIST_PATTERNS = "sandboxing.whitelist.patterns";

    // ...

    public static void main(String[] args) {
        // ...
        try {
            Map<String,ClassNode> variableTypes = new HashMap<String, ClassNode>();
            variableTypes.put("score", ClassHelper.double_TYPE);
            Map<String,Object> options = new HashMap<String, Object>();
            List<String> patterns = new ArrayList<String>();
            patterns.add("java\\.lang\\.Math#");
            options.put(VAR_TYPES, variableTypes);
            options.put(WHITELIST_PATTERNS, patterns);
            COMPILE_OPTIONS.set(options);
            GroovyShell shell = new GroovyShell(binding, conf);
            Double userScore = (Double) shell.evaluate("abs(cos(1+score));System.exit(-1)");
            System.out.println("userScore = " + userScore);
        } finally {
            COMPILE_OPTIONS.remove();
        }
    }
}

  1. объявляем список паттернов
  2. добавляем все методы java.lang.Math как разрешенные
  3. помещаем белый список в конфигурацию проверок типов

Затем на стороне расширения для проверки типов:


import groovy.transform.CompileStatic
import org.codehaus.groovy.ast.ClassNode
import org.codehaus.groovy.ast.MethodNode
import org.codehaus.groovy.ast.Parameter
import org.codehaus.groovy.transform.stc.ExtensionMethodNode

import static Sandbox.*

@CompileStatic
private static String prettyPrint(ClassNode node) {
    node.isArray()?"${prettyPrint(node.componentType)}[]":node.toString(false)
}

@CompileStatic
private static String toMethodDescriptor(MethodNode node) {
    if (node instanceof ExtensionMethodNode) {
        return toMethodDescriptor(node.extensionMethodNode)
    }
    def sb = new StringBuilder()
    sb.append(node.declaringClass.toString(false))
    sb.append("#")
    sb.append(node.name)
    sb.append('(')
    sb.append(node.parameters.collect { Parameter it ->
        prettyPrint(it.originType)
    }.join(','))
    sb.append(')')
    sb
}
def typesOfVariables = COMPILE_OPTIONS.get()[VAR_TYPES]
def whiteList = COMPILE_OPTIONS.get()[WHITELIST_PATTERNS]

onMethodSelection { expr, MethodNode methodNode ->
    def descr = toMethodDescriptor(methodNode)
    if (!whiteList.any { descr =~ it }) {
        addStaticTypeError("You tried to call a method which is not allowed, what did you expect?: $descr", expr)
    }
}

unresolvedVariable { var ->
    if (typesOfVariables[var.name]) {
        return makeDynamic(var, typesOfVariables[var.name])
    }
}

  1. этот метод генерирует дескриптор метода из MethodNode
  2. извлекаем белый список из thread local
  3. конвертируем выбранный метод в строку дескриптора
  4. если этот дескриптор не совпадает с белым списком, генерируется ошибка

И если вы снова запустите код, получите очень классную ошибку:


Script1.groovy: 1: [Static type checking] - You tried to call a method which is not allowed, what did you expect?: java.lang.System#exit(int)
 @ line 1, column 19.
   abs(cos(1+score));System.exit(-1)
                     ^

1 error

То, что нужно! Теперь у нас есть расширение для проверки типов, которое обрабатывает и типы переменных, и белый список разрешенных методов. Все еще не идеально, но очень близко к итоговому решению! Мы не нашли идеального решения, потому что позаботились только о вызовах методов, но нужно разобраться еще кое с чем. Например, со свойствами (такими как foo.text, которые неявно конвертируются в foo.getText()).


Собираем все вместе


Разобраться со свойствами немного сложнее, потому что у type checker’а не средств для "property selection", такого, как для методов. Для этого можно найти обходное решение, и если вам интересно взглянуть на код этого решения, смотрите ниже. Это расширение для проверки типов написано не совсем так, как остальные в этом посте, потому что в этом случае цель — предкомпиляция для улучшения производительности. Но идея та же самая.


SandboxingTypeCheckingExtension.groovy


import groovy.transform.CompileStatic
import org.codehaus.groovy.ast.ClassCodeVisitorSupport
import org.codehaus.groovy.ast.ClassHelper
import org.codehaus.groovy.ast.ClassNode
import org.codehaus.groovy.ast.MethodNode
import org.codehaus.groovy.ast.Parameter
import org.codehaus.groovy.ast.expr.PropertyExpression
import org.codehaus.groovy.control.SourceUnit
import org.codehaus.groovy.transform.sc.StaticCompilationMetadataKeys
import org.codehaus.groovy.transform.stc.ExtensionMethodNode
import org.codehaus.groovy.transform.stc.GroovyTypeCheckingExtensionSupport
import org.codehaus.groovy.transform.stc.StaticTypeCheckingSupport

import static Sandbox.*

class SandboxingTypeCheckingExtension extends GroovyTypeCheckingExtensionSupport.TypeCheckingDSL {

    @CompileStatic
    private static String prettyPrint(ClassNode node) {
        node.isArray()?"${prettyPrint(node.componentType)}[]":node.toString(false)
    }

    @CompileStatic
    private static String toMethodDescriptor(MethodNode node) {
        if (node instanceof ExtensionMethodNode) {
            return toMethodDescriptor(node.extensionMethodNode)
        }
        def sb = new StringBuilder()
        sb.append(node.declaringClass.toString(false))
        sb.append("#")
        sb.append(node.name)
        sb.append('(')
        sb.append(node.parameters.collect { Parameter it ->
            prettyPrint(it.originType)
        }.join(','))
        sb.append(')')
        sb
    }

    @Override
    Object run() {

        // Fetch white list of regular expressions of authorized method calls
        def whiteList = COMPILE_OPTIONS.get()[WHITELIST_PATTERNS]
        def typesOfVariables = COMPILE_OPTIONS.get()[VAR_TYPES]

        onMethodSelection { expr, MethodNode methodNode ->
            def descr = toMethodDescriptor(methodNode)
            if (!whiteList.any { descr =~ it }) {
                addStaticTypeError("You tried to call a method which is not allowed, what did you expect?: $descr", expr)
            }
        }

        unresolvedVariable { var ->
            if (isDynamic(var) && typesOfVariables[var.name]) {
                storeType(var, typesOfVariables[var.name])
                handled = true
            }
        }

        // handling properties (like foo.text) is harder because the type checking extension
        // does not provide a specific hook for this. Harder, but not impossible!

        afterVisitMethod { methodNode ->
            def visitor = new PropertyExpressionChecker(context.source, whiteList)
            visitor.visitMethod(methodNode)
        }
    }

    private class PropertyExpressionChecker extends ClassCodeVisitorSupport {
        private final SourceUnit unit
        private final List<String> whiteList

        PropertyExpressionChecker(final SourceUnit unit, final List<String> whiteList) {
            this.unit = unit
            this.whiteList = whiteList
        }

        @Override
        protected SourceUnit getSourceUnit() {
            unit
        }

        @Override
        void visitPropertyExpression(final PropertyExpression expression) {
            super.visitPropertyExpression(expression)

            ClassNode owner = expression.objectExpression.getNodeMetaData(StaticCompilationMetadataKeys.PROPERTY_OWNER)
            if (owner) {
                if (expression.spreadSafe && StaticTypeCheckingSupport.implementsInterfaceOrIsSubclassOf(owner, classNodeFor(Collection))) {
                    owner = typeCheckingVisitor.inferComponentType(owner, ClassHelper.int_TYPE)
                }
                def descr = "${prettyPrint(owner)}#${expression.propertyAsString}"
                if (!whiteList.any { descr =~ it }) {
                    addStaticTypeError("Property is not allowed: $descr", expression)
                }
            }
        }
    }
}```

А вот финальная версия sandbox’а, в которую мы добавили assert’ы , чтобы удостовериться, что мы обработали все случаи:

``Sandbox.java``
```java
public class Sandbox {
    public static final String WHITELIST_PATTERNS = "sandboxing.whitelist.patterns";
    public static final String VAR_TYPES = "sandboxing.variable.types";

    public static final ThreadLocal<Map<String, Object>> COMPILE_OPTIONS = new ThreadLocal<Map<String, Object>>();

    public static void main(String[] args) {
        CompilerConfiguration conf = new CompilerConfiguration();
        ImportCustomizer customizer = new ImportCustomizer();
        customizer.addStaticStars("java.lang.Math");
        ASTTransformationCustomizer astcz = new ASTTransformationCustomizer(
                singletonMap("extensions", singletonList("SandboxingTypeCheckingExtension.groovy")),
                CompileStatic.class);
        conf.addCompilationCustomizers(astcz);
        conf.addCompilationCustomizers(customizer);

        Binding binding = new Binding();
        binding.setVariable("score", 2.0d);
        try {
            Map<String, ClassNode> variableTypes = new HashMap<String, ClassNode>();
            variableTypes.put("score", ClassHelper.double_TYPE);
            Map<String, Object> options = new HashMap<String, Object>();
            List<String> patterns = new ArrayList<String>();
            // allow method calls on Math
            patterns.add("java\\.lang\\.Math#");
            // allow constructors calls on File
            patterns.add("File#<init>");
            // because we let the user call each/times/...
            patterns.add("org\\.codehaus\\.groovy\\.runtime\\.DefaultGroovyMethods");
            options.put(VAR_TYPES, variableTypes);
            options.put(WHITELIST_PATTERNS, patterns);
            COMPILE_OPTIONS.set(options);
            GroovyShell shell = new GroovyShell(binding, conf);
            Object result;
            try {
                result = shell.evaluate("Eval.me('1')"); // error
                assert false;
            } catch (MultipleCompilationErrorsException e) {
                System.out.println("Successful sandboxing: "+e.getMessage());
            }
            try {
                result = shell.evaluate("System.exit(-1)"); // error
                assert false;
            } catch (MultipleCompilationErrorsException e) {
                System.out.println("Successful sandboxing: "+e.getMessage());
            }
            try {
                result = shell.evaluate("((Object)Eval).me('1')"); // error
                assert false;
            } catch (MultipleCompilationErrorsException e) {
                System.out.println("Successful sandboxing: "+e.getMessage());
            }

            try {
                result = shell.evaluate("new File('/etc/passwd').getText()"); // getText is not allowed
                assert false;
            } catch (MultipleCompilationErrorsException e) {
                System.out.println("Successful sandboxing: "+e.getMessage());
            }

            try {
                result = shell.evaluate("new File('/etc/passwd').text");  // getText is not allowed
                assert false;
            } catch (MultipleCompilationErrorsException e) {
                System.out.println("Successful sandboxing: "+e.getMessage());
            }

            Double userScore = (Double) shell.evaluate("abs(cos(1+score))");
            System.out.println("userScore = " + userScore);
        } finally {
            COMPILE_OPTIONS.remove();
        }
    }
}

Заключение


Этот пост исследует вопрос использования Groovy в качестве платформы для скриптинга на JVM. Он рассказывает о различных механизмах интеграции и показывает, что за это приходится платить безопасностью. Однако, мы обозначили несколько концепций, таких как кастомайзеры компиляции, которые упрощают изоляцию среды выполнения скриптов. Существующие кастомайзеры, доступные в дистрибутиве Groovy, и доступные готовые проекты по sandboxing’у в общем случае не могут гарантировать безопасность запуска скриптов (конечно, это зависит от пользователей и от того, откуда пришел скрипт).


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


В итоге мы выяснили, что механизм sandboxing’а, который мы проиллюстрировали, — это не замена SecureASTCustomizer. Мы рекомендуем использовать и то, и другое, потому что они работают на разных уровнях: secure AST customizer работает на уровне грамматики, позволяя ограничивать некоторые конструкции языка (например, запретить создание замыканий или классов внутри скрипта), в то время как расширения для проверки типов будут работать после вывода типов (позволяя оперировать выведенными типами, а не объявленными).


Последнее, но не менее важное: решение, описанное мной здесь, неполно. В ядре Groovy оно не доступно. Поскольку у меня все меньше времени для работы над Groovy, я буду рад, если кто-нибудь улучшит это решение и сделает pull request, чтобы у нас было хоть какое-то полноценное решение!

Haulmont
71,17
Создаем современные корпоративные системы
Поделиться публикацией

Похожие публикации

Комментарии 1

    +1
    Электросварка не в перчатках? Это ненадолго!

    Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

    Самое читаемое