Skip to content

Безопасное выполнение стороннего кода в Node.js

18/03/2010

Итак, следующий пункт после game loop — система скриптов. Понятное дело, это будет JavaScript. Но я хочу добиться большего — чтобы можно было исполнять сторонний код без боязни уронить всё приложение.

Вообще говоря Node падает довольно легко. Любая синтаксическая ошибка (в том числе в подключаемом модуле) или обращение к несуществующему методу — и всё, game over. Этого я и хочу избежать. Можно будет, например, сделать игру наподобие Habrawars (или даже на её основе), но проводящую бои между роботами участников автоматически, раз в час например. В HabraWars мне этого очень не хватало 🙂

Для безопасного выполнения кода у нас не так много вариантов. Eval, Evalcx, дочерний процесс.

  • Eval. Именно этим путём пошёл создатель Habrawars. Код выполняется в контролируемом окружении, ошибки в коде легко ловить. Минусы — коду доступен глобальный контекст, его конечно можно изолировать, но не то чтобы это было просто. В Habrawars например был не закрыт (и сейчас не закрыт, я полагаю) console.log — мелочь конечно, но можно пропустить и что нибудь посерьёзнее.
  • Evalcx. Улучшенная версия eval, позволяющая задавать произвольный контекст выполняемому коду. Пришла в Node.js прямиком из Spidermonkey.
  • Дочерний процесс. Ничего не мешает нам запустить дочерний экземпляр node с нужным скриптом. Плюс: мы можем прибить дочерний процесс, если вдруг он решит войти в бесконечный цикл. Минус: дочернему процессу доступно очень много функций, в том числе запись в файлы и обращение в сеть. Впрочем, можно ограничить если не его функциональность, то хотя бы максимальный возможный ущерб — например, запустив его под пользователем с урезанными правами.

Т.к. eval, вообще говоря, выполняется в глобальном контексте и код внутри него имеет доступ к разным интересным штукам вплоть до сети и файловой системы, рассмотрим два оставшихся варианта по очереди. Давайте возьмём pulse.js из предыдущего примера, и используем интервальный таймер для организации game loop. Теперь нам надо создать NPC, который будет загружаться из другого файла (и не ронять основной цикл Pulse в случае ошибки в файле).

Evalcx

Схема возврата тут довольно странная. Из evalcx будет возвращена последняя присвоенная свойству объекта this функция. Что ж, логично таким образом передать функцию-конструктор 🙂 Пишем npc-«кошку», с тремя методами:

  • move — кошка делает шаг
  • hide — кошка возвращается на исходную позицию
  • find — возвращает координаты кошки

Набор методов может быть любым, этот выбран исключительно в целях демонстрации. Скрипт кошки выглядит так:

this.cat = function(x, y) {
    var x = 0;
    var y = 0;

    this.move = function() {
        x = x + 1;
    };

    this.hide = function() {
        x = 0;
        y = 0;
    };

    this.find = function() {
        return x;
    };
};

Вот так, без возвратов, почти как в CommonJS. Просто присваиваем метод глобальному объекту.

Теперь нам надо получить этот код из Pulse-сервера. Я вставлю сразу рабочий вариант, без промежуточных:

var sys = require("sys"),
    http = require("http"),
    events = require("events"),
    fs = require("fs");

var cat_file = fs.openSync('./cat.js', 'r', 0644);
// Здесь мы сохраняем код в переменную для дальнейшего использования — просто как текст
var cat_code = fs.readSync(cat_file, 10000, 0, 'ascii');

var cat = {};
// Выполняем код в контексте переменной cat
// она будет доступна в cat.js как this
try {
    var out = process.evalcx(cat_code[0], {});
    cat = new out(1,2);
    cat.move();
} catch(e) {
    sys.puts('cat compilation error');
    sys.puts(e);
}

(function(){
    var pulse_num = 0;
    var heartbeat = new events.EventEmitter();

    var start = (new Date).getTime();

    heartbeat.addListener('beat', function() {

        pulse_num += 1;

        if (pulse_num % 2) {
            cat.move();
        }

        sys.puts('Cat position on beat #' + pulse_num + ': ' + cat.find());

        if (pulse_num >= 60) {
            process.exit(0);
        }

        setTimeout(function() {
            heartbeat.emit('beat');
        }, 40);
    });

    http.createServer(function(request, response) {
        response.sendHeader(200, {"Content-Type": "text/plain"});
        response.write("Hello, World. Pulse time is " +  pulse_num + "\n");
        response.close();
    }).listen(8000);

    sys.puts("Pulse server running at http://192.168.175.128:8000/");

    if (cat != {}) {
        heartbeat.emit('beat');
    }

    sys.puts("Pulse beat started");

})();

Здесь код читается из файла с помощью синхронных функций (чтобы не разбираться с лапшой callback’ов). После этого полученный код выполняется в контексте пустого объекта — это значит что он не получит доступа ни к уже подключенным модулям, ни к глобально доступным объектам Node вроде process. Одно плохо — от зацикливания внутри «кошачьей логики» он нас не спасёт.

После запуска мы увидим примерно следующий вывод:

Pulse server running at http://192.168.175.128:8000/
Cat position on beat #1: 2
Pulse beat started
Cat position on beat #2: 2
Cat position on beat #3: 3
Cat position on beat #4: 3
Cat position on beat #5: 4
Cat position on beat #6: 4
Cat position on beat #7: 5
Cat position on beat #8: 5
Cat position on beat #9: 6
Cat position on beat #10: 6
Cat position on beat #11: 7
Cat position on beat #12: 7
Cat position on beat #13: 8
Cat position on beat #14: 8
Cat position on beat #15: 9
Cat position on beat #16: 9
Cat position on beat #17: 10
^C

Как видите, я просто убиваю node по Ctrl+C, потому что выхода не предусмотрено. Строчки идут довольно быстро, но читаемо (задержка 40мс + время выполнения итерации). Теперь попробуем другой способ.

Дочерний процесс

Здесь cat будет вполне самостоятельной программой со своим event loop и своми listeners. При большом количестве независимых NPC такая схема будет работать не очень быстро (за счёт переключений контекста между всеми участниками). Но ради эксперимента попробуем. Файл cat.js должен быть переписан следующим образом:

var sys = require('sys');

var cat = function(x, y) {
    var x = 0;
    var y = 0;

    this.move = function() {
        x = x + 1;
    };

    this.hide = function() {
        x = 0;
        y = 0;
    };

    this.find = function() {
        return x;
    };
};

var barsik = new cat(0, 0);

// Открываем stdio. Теперь скрипт не завершится, пока не будет вызван process.stdio.close();
process.stdio.open('ascii');

process.stdio.addListener('data', function(message_raw) {
    var messages  = message_raw.split('\n');

    for (message_id in messages) {
        try {
            message = JSON.parse(messages[message_id]);
        } catch(e) {
            sys.puts('message: ' + messages[message_id] + '; error: ' + e);
            message = {};
        }

        if (message.action == 'move') {
            barsik.move();
        } else if (message.action == 'hide') {
            barsik.hide();
        } else if (message.action == 'find') {
            process.stdio.write(JSON.stringify({x:barsik.find()}), 'ascii');
        } else if (message.action == 'close') {
            process.stdio.close();
            exit(0);
        }
    }
});

Обратите внимание на использование стандартного ввода/вывода с помощью process.stdio и разбор принятых сообщений на строке 27 (выделена). Здесь мы разделяем принятые кусочки JSON по символу перевода строки. Делается это вот зачем: стандартный ввод-вывод в Node действует асинхронно (кроме stderr). Если «родитель» отправляет «потомку» несколько сообшений подряд, они могут быть приняты в виде одного целого сообщения. Строгий стандарт JSON не выдерживает такого надругательства, и парсер JSON.parse бросает исключение.

Теперь посмотрим, как организовано взаимодействие с дочерним процессом в pulse.js:

var sys = require("sys"),
    http = require("http"),
    events = require("events"),
    fs = require("fs");

(function(){
    var pulse_num = 0;
    var heartbeat = new events.EventEmitter();

    var cat = process.createChildProcess('node', ['cat.js', '2']);

    var virt_cat = {x:0, y:0};
    cat.addListener('output', function(data) {
        // sys.puts('data from child on ' + pulse_num + ':' + data);

        var position = {};

        try {
            position = JSON.parse(data);
        } catch(e) {
            sys.puts('[P]message: ' + data + '; error: ' + e);
            position = {};
        }

        if (position.x) {
            virt_cat.x = position.x;
        }
    });

    var start = (new Date).getTime();

    heartbeat.addListener('beat', function() {

        pulse_num += 1;

        if (pulse_num % 2) {
            cat.write(JSON.stringify({action:"move"}) + "\n", 'ascii');
        }

        cat.write(JSON.stringify({action:'find'}) + "\n", 'ascii');
        sys.puts('Virtual cat position on beat ' + pulse_num + " is " + virt_cat.x);

        if (pulse_num >= 60) {
            process.exit(0);
        }

        setTimeout(function() {
            heartbeat.emit('beat');
        }, 40);
    });

    http.createServer(function(request, response) {
        response.sendHeader(200, {"Content-Type": "text/plain"});
        response.write("Hello, World. Pulse time is " +  pulse_num + "\n");
        response.close();
    }).listen(8000);

    sys.puts("Pulse server running at http://192.168.175.128:8000/");

    if (cat != {}) {
        heartbeat.emit('beat');
    }

    sys.puts("Pulse beat started");

})();

Здесь всё несколько сложнее, чем в evalcx-примере, но и возможностей намного больше. Во-первых, Pulse и Cat обмениваются полноценным JSON — можно передавать довольно много структурированных данных. Во вторых, можно ставить таймер на ответ Cat и если за нужное время ответ не получен — убивать (и возможно перезапускать) дочерний процесс. Теперь скрипту будет непросто подвесить основной движок. С другой стороны, при такой организации кода движок не ждёт ответа от «кошки», чтобы перейти к следующей итерации game loop. В принципе это можно исправить, делая beat.emit() на чётных итерациях в обработчике сообщений от потомка вместо обработчика самого события beat, либо с помощью combo-библиотеки.

Сейчас немного объясню код в отмеченных местах. Когда мы создаём дочерний процесс, у нас есть выбор, как с ним общаться. Можно либо повесить универсальный обработчик в самом начале, либо вешать обработчик при отсылке команд, а потом удалять его. В нашем случае удобнее первый вариант (т.к. команды могут уходить и приходить целыми пакетами). Но возникает проблема передачи данных из обработчика (объявленного раньше) движку (который объявлен и запущен позже). Здесь я решил её объявив «виртуальную кошку» перед обработчиком ответов от Cat и движком, и доступную обеим функциям. При получении сообщения (или сообщений) обработчик обновляет соответствующие параметры «виртуальной кошки», и на следующей итерации основного цикла их уже можно использовать.

Примерный вывод этого варианта скрипта:

Pulse server running at http://192.168.175.128:8000/
Virtual cat position on beat 1 is 0
Pulse beat started
Virtual cat position on beat 2 is 0
Virtual cat position on beat 3 is 0
Virtual cat position on beat 4 is 0
Virtual cat position on beat 5 is 2
Virtual cat position on beat 6 is 3
Virtual cat position on beat 7 is 3
Virtual cat position on beat 8 is 4
Virtual cat position on beat 9 is 4
Virtual cat position on beat 10 is 5

Pulse beat started выводится после первой итерации игрового цикла потому что в pulse.js вывод этой строки происходит уже после стартового beat.emit(), который выполняется в той же итерации event loop.

И наконец, можно комбинировать оба подхода — порождать дочерний процесс и выполнять код внутри него с помощью evalcx. Это, во первых, даст нам контроль над максимальным временем выполнения и, во-вторых, выполнит код пользователя в безопасном стерильном (или нужном нам) контексте.

P.S. Сегодня наконец собрал и организовал свои статьи по Node в удобное оглавление.

2 комментария
  1. Владимир permalink

    Здесь стоило бы больше упор сделать на следующий функционал модуля vm:
    vm.runInThisContext(code, [filename])
    vm.runInNewContext(code, [sandbox], [filename])
    vm.runInContext(code, context, [filename])
    vm.createContext([initSandbox])
    vm.createScript(code, [filename])

Trackbacks & Pingbacks

  1. Безопасное выполнение стороннего кода в Node.js « nodeJS

Добавить комментарий

Заполните поля или щелкните по значку, чтобы оставить свой комментарий:

Логотип WordPress.com

Для комментария используется ваша учётная запись WordPress.com. Выход / Изменить )

Фотография Twitter

Для комментария используется ваша учётная запись Twitter. Выход / Изменить )

Фотография Facebook

Для комментария используется ваша учётная запись Facebook. Выход / Изменить )

Google+ photo

Для комментария используется ваша учётная запись Google+. Выход / Изменить )

Connecting to %s

%d такие блоггеры, как: