Простой способ запускать доверенный пользовательский JavaScript в Node.js

от автора

Недавно я опубликовал большую обзорную статью о том, как несколько лет развиваю свой пет-проект, который постепенно превратился в собственную Function-as-a-Service платформу. Она позволяет принимать пользовательский JavaScript, Python или Go, запускать его в изолированном окружении и управлять такими функциями почти как в небольшом облаке. Подробнее об истории проекта и его архитектуре можно прочитать в предыдущей статье тут.

В этот раз я хочу разобрать более узкую тему и рассказать, с чего вообще начинались мои эксперименты с выполнением пользовательского JavaScript в Node.js. Мы посмотрим на самые простые встроенные инструменты, а также проверим, способен ли V8 оптимизировать код, созданный динамически во время работы приложения.

Сразу обозначу важное ограничение. Все примеры ниже предназначены исключительно для запуска доверенного кода. То есть мы заранее контролируем источник программы и можем гарантировать, что она не будет пытаться читать файлы, обращаться к переменным окружения, подключаться к базе данных, завершать процесс, бесконечно занимать процессор или каким-либо другим способом атаковать приложение и инфраструктуру. Покажу способы, как частино(!) закрыть дырки.

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

Необходимость динамически выполнять JavaScript возникает в самых разных системах. Это могут быть:

  • пользовательские обработчики;

  • правила маршрутизации;

  • формулы и преобразования данных;

  • плагины;

  • сценарии автоматизации;

  • фоновые задачи;

  • функции в serverless-платформе.

В самом простом случае приложение получает строку:

const source = `  return input.value * 2;`;

и должно превратить её в вызываемую функцию.

В JavaScript для этого есть два очевидных инструмента:

eval(source);

и:

new Function(source);

Оба выполняют код, созданный во время работы программы, но ведут себя по-разному.

Почему прямой eval обычно является плохим выбором

Думаю сюда можно сразу вставить кусочек из MDN.

Предупреждение - никогда не используйте eval()

Предупреждение — никогда не используйте eval()

Прямой вызов eval выполняет код в текущем лексическом окружении:

function executeUserCode(source) {  const secret = "internal-token";  return eval(source);}console.log(executeUserCode("secret"));// internal-token

Динамический код видит локальные переменные вызывающей функции. Он также может изменять их:

function example() {  let value = 10;  eval("value = 100");  return value;}console.log(example());// 100

Это создаёт сразу несколько проблем.

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

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

Существует также косвенный вызов eval:

const indirectEval = eval;indirectEval(`  console.log(typeof localVariable);`);

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

new Function как простой компилятор

Конструктор Function создаёт функцию из строковых аргументов:

const multiply = new Function(  "a",  "b",  `    "use strict";    return a * b;  `,);console.log(multiply(4, 5));// 20

Первые аргументы становятся параметрами функции, а последний содержит её тело.

В отличие от прямого eval, функция не замыкается на локальное окружение, в котором была создана:

function createHandler() {  const secret = "internal-token";  return new Function(`    return typeof secret;  `);}console.log(createHandler()());// undefined

Это позволяет явно передавать пользовательскому обработчику только необходимые данные и API:

function compileUserHandler(source) {  return new Function(    "input",    "api",    `      "use strict";      return (async function userHandler() {        ${source}      })();    `,  );}

Теперь можно скомпилировать обработчик:

const handler = compileUserHandler(`  const user = await api.getUser(input.userId);  return {    id: user.id,    name: user.name,  };`);

И передать ему зависимости при вызове:

const result = await handler(  {    userId: 42,  },  {    getUser: async id => ({      id,      name: "Alex",    }),  },);console.log(result);

Вместо доступа ко всему приложению пользовательская функция получает конкретный объект api. Это делает зависимости более понятными и позволяет контролировать доступные операции.

Однако важно не путать явную передачу зависимостей с безопасной изоляцией. Функция, созданная через new Function, всё ещё выполняется внутри текущего процесса и видит глобальное окружение Node.js.

Оптимизирует ли V8 функции из new Function

Иногда можно встретить предположение, что динамически созданный JavaScript всегда остаётся медленным или выполняется только интерпретатором.

На практике функция, созданная через new Function, становится обычной функцией V8. Если она вызывается достаточно часто и работает со стабильными типами, движок может оптимизировать её так же, как функцию из исходного файла.

Проверить это можно с помощью диагностических флагов V8.

В PowerShell достаточно выполнить одну команду:

node --trace-opt --trace-deopt -e 'const f = new Function(`return function myFunction(x) { return (x + 1) * 2 }`)(); let result = 0; for (let i = 0; i < 1000000; i++) result += f(i); console.log(result);' 2>&1 | Select-String 'myFunction|optimizing|compiling|deopt'

На Node.js 24.6.0 результат выглядел примерно так:

[marking ... <JSFunction myFunction ...> for optimization to MAGLEV, reason: hot and stable][compiling method ... <JSFunction myFunction ...> (target MAGLEV)][completed compiling ... <JSFunction myFunction ...> (target MAGLEV)]

Наиболее показательная часть:

reason: hot and stable

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

После этого она была скомпилирована Maglev:

target MAGLEVcompleted compiling

В полном выводе также могли появиться сообщения о деоптимизации внешней анонимной функции, содержащей цикл и console.log. При этом у myFunction был другой sfi, и сообщений о её деоптимизации не было.

Таким образом, new Function не отключает JIT-компиляцию. Динамически созданная функция проходит обычный путь выполнения V8 и может быть автоматически оптимизирована.

Компилировать один раз, запускать много раз

Способность функции оптимизироваться не означает, что её создание ничего не стоит.

При каждом вызове:

new Function(source);

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

function execute(source, input) {  const handler = new Function("input", source);  return handler(input);}

Лучше отделить компиляцию от запуска:

const cache = new Map();function getHandler(source) {  let handler = cache.get(source);  if (!handler) {    handler = new Function(      "input",      `        "use strict";        ${source}      `,    );    cache.set(source, handler);  }  return handler;}function execute(source, input) {  return getHandler(source)(input);}

Теперь функция создаётся один раз, а затем многократно вызывается. Благодаря этому она может накопить информацию о типах, прогреться и перейти на оптимизированный уровень выполнения.

В реальной системе вместо полного исходного текста в качестве ключа можно использовать хеш:

import { createHash } from "node:crypto";const cache = new Map();function getSourceHash(source) {  return createHash("sha256")    .update(source)    .digest("hex");}function compile(source) {  const key = getSourceHash(source);  if (!cache.has(key)) {    const handler = new Function(      "input",      "api",      `        "use strict";        return (async function userHandler() {          ${source}        })();      `,    );    cache.set(key, handler);  }  return cache.get(key);}

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

Почему new Function не является песочницей

Отсутствие доступа к локальным переменным не означает, что функция изолирована.

В Node.js она всё ещё видит глобальные объекты:

const handler = new Function(`  return {    pid: process.pid,    env: process.env,  };`);console.log(handler());

Она может завершить приложение:

new Function(`  process.exit(1);`)();

Или полностью заблокировать event loop:

new Function(`  while (true) {}`)();

Поэтому new Function полезен как механизм динамической компиляции, но не как механизм безопасности.

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

Если же код приходит от произвольного пользователя, одного new Function недостаточно. Однако я все еще нашел очень неплохое применение new Function , когда пробовал разработать эмулятор для PSX, который запускается в браузере. Если интересно посмотреть исходники и рабочий вариант:

1. Исходники: https://github.com/kubarskii/PSX-emu-JS
2. Рабочая версия(могу сломать): https://aleksandr-kubarskii-0931e2-psx-d82ded.inquir.org/

Следующий шаг: отдельный контекст через node:vm

После экспериментов с new Function мне захотелось получить более управляемое глобальное окружение. Следующим шагом стал встроенный модуль node:vm.

Он позволяет создать отдельный JavaScript-контекст и явно определить, какие значения будут доступны исполняемому коду:

const vm = require("node:vm");const context = vm.createContext({  input: {    value: 42,  },});const script = new vm.Script(`  input.value * 2;`);const result = script.runInContext(context);console.log(result);// 84

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

Например:

const context = vm.createContext({  input: {    userId: 42,  },  api: {    getUser: async id => ({      id,      name: "Alex",    }),  },});

Однако передавать в контекст объекты основного приложения всё равно нужно осторожно. Если переданный объект открывает доступ к файловой системе, базе данных или сети, пользовательский код сможет воспользоваться этими возможностями.

Отключение генерации кода

При создании контекста можно запретить генерацию нового кода из строк и WebAssembly:

const context = vm.createContext(  {    input: {      value: 42,    },  },  {    codeGeneration: {      strings: false,      wasm: false,    },  },);

Теперь код внутри контекста не сможет использовать собственный eval или new Function:

const script = new vm.Script(`  new Function("return 42")();`);script.runInContext(context);

Такой вызов завершится ошибкой.

Это полезное ограничение, но оно всё равно не делает node:vm полноценной защитой от намеренно вредоносного кода.

Фильтрация контекста

В первой версии раннера я дополнительно очищал переданный контекст от потенциально опасных значений:

function sanitizeContext(context) {  const denied = new Set([    "require",    "global",    "process",    "child_process",    "fs",    "vm",  ]);  return Object.fromEntries(    Object.entries(context).filter(([key]) => !denied.has(key)),  );}

После этого очищенный объект передавался в vm.createContext:

function createContext(context = {}) {  const sanitizedContext = sanitizeContext(context);  return vm.createContext(sanitizedContext, {    codeGeneration: {      strings: false,      wasm: false,    },  });}

Такой blacklist может защитить от случайной ошибки, например если разработчик по невнимательности передал в контекст process. Но как модель безопасности он слабый: всегда можно забыть добавить очередной опасный объект.

Надёжнее использовать whitelist и начинать с минимального окружения:

const context = vm.createContext({  console: safeConsole,  input,  api: {    loadUser,    saveResult,  },});

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

Поддержка функций и CommonJS-модулей

Мне хотелось, чтобы раннер принимал не только выражения, но и код, похожий на обычные Node.js-модули.

Пользователь мог передать простую функцию:

async input => {  return {    value: input.value * 2,  };}

Чтобы такой исходник можно было выполнить через vm.Script, его достаточно превратить в выражение:

const wrappedSource = `  (    async input => {      return {        value: input.value * 2,      };    }  )`;

После выполнения скрипт вернёт готовую функцию:

const script = new vm.Script(wrappedSource);const handler = script.runInContext(context);

Для CommonJS-кода можно использовать обёртку, похожую на внутреннюю модульную обёртку Node.js:

function wrapCommonJS(source) {  return `    (function (exports, require, module, __filename, __dirname) {      "use strict";      ${source}    })  `;}

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

module.exports = async function handler(input) {  return {    value: input.value * 2,  };};

После компиляции создаются module, exports и остальные аргументы:

const exports = {};const module = { exports };wrapper(  exports,  restrictedRequire,  module,  filename,  dirname,);const handler = module.exports;

При необходимости ESM-код можно предварительно преобразовать в CommonJS, а затем выполнить через ту же обёртку.

Ограниченный require

Обычный require нельзя без ограничений передавать пользовательскому коду. Иначе он сможет подключить node:fs, node:child_process или любой установленный пакет.

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

const nativeRequire = require;function createRestrictedRequire(access = {}) {  return function restrictedRequire(moduleName) {    const allowed = Object.entries(access).some(      ([pattern, enabled]) => {        if (!enabled) {          return false;        }        if (pattern === moduleName) {          return true;        }        if (pattern.endsWith("*")) {          const prefix = pattern.slice(0, -1);          return moduleName.startsWith(prefix);        }        return false;      },    );    if (!allowed) {      throw new Error(        `Access denied to module "${moduleName}"`,      );    }    return nativeRequire(moduleName);  };}

Конфигурация может выглядеть так:

const access = {  "node:path": true,  "@inquir/sdk": true,  "@inquir/utils/*": true,};

Точная запись разрешает один модуль:

"node:path": true

Звёздочка разрешает группу модулей по префиксу:

"@inquir/utils/*": true

Такой подход позволяет дать пользовательской функции небольшой SDK и несколько проверенных библиотек вместо доступа ко всему окружению Node.js.

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

Важный нюанс, что до изначального require, все равно можно добраться используя встроенные объекты и цепочку прототипов.

Имя файла и нормальные стеки ошибок

При создании vm.Script можно указать имя файла:

const script = new vm.Script(source, {  filename: "functions/process-order.js",  displayErrors: true,});

Без этого ошибки часто ссылаются на что-то вроде:

evalmachine.<anonymous>

С указанным filename стек становится намного понятнее:

functions/process-order.js:12  return order.items.map(...)                     ^TypeError: Cannot read properties of undefined

Это особенно полезно, если в системе одновременно выполняются десятки или сотни пользовательских функций.

Можно также загружать скрипты из файлов:

const fsp = require("node:fs/promises");const path = require("node:path");async function createScriptFromFile(filePath) {  const absolutePath = path.resolve(filePath);  const source = await fsp.readFile(    absolutePath,    "utf8",  );  return new vm.Script(source, {    filename: absolutePath,    displayErrors: true,  });}

В результате ошибки будут указывать на реальный путь и строку исходного файла.

Важный нюанс с тайм-аутом

vm.Script позволяет ограничить время синхронного выполнения:

const script = new vm.Script(`  while (true) {}`);script.runInContext(context, {  timeout: 1_000,});

Через секунду выполнение будет остановлено.

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

Если скрипт лишь возвращает функцию:

const script = new vm.Script(`  function handler() {    while (true) {}  }  handler;`);const handler = script.runInContext(context, {  timeout: 1_000,});

во время runInContext создаётся объект функции, но её тело ещё не выполняется.

Последующий вызов:

handler();

происходит отдельно и уже не ограничен указанным тайм-аутом.

То же относится к CommonJS-обёртке. Если vm.Script возвращает wrapper-функцию, а она вызывается позже из основного кода, тайм-аут защищает только этап создания wrapper, но не его выполнение.

Чтобы лимит распространялся на вызов, сам вызов тоже должен происходить внутри runInContext:

const context = vm.createContext({  handler,  input,  result: undefined,});const invocation = new vm.Script(`  result = handler(input);`);invocation.runInContext(context, {  timeout: 5_000,});

Но и этот подход имеет ограничения.

Тайм-аут runInContext контролирует синхронное выполнение JavaScript. Он не превращается автоматически в полноценный лимит для промисов, таймеров, сетевых запросов и других асинхронных операций.

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

Практический раннер

Упрощённый раннер для доверенного кода может выглядеть так:

const vm = require("node:vm");function compileHandler(source, options = {}) {  const {    filename = "user-handler.js",    timeout = 5_000,  } = options;  const context = vm.createContext(    {      input: undefined,      api: undefined,      result: undefined,    },    {      codeGeneration: {        strings: false,        wasm: false,      },    },  );  const script = new vm.Script(    `      const handler = async function userHandler(        input,        api      ) {        "use strict";        ${source}      };      result = handler(input, api);    `,    {      filename,      displayErrors: true,    },  );  return async function execute(input, api) {    context.input = input;    context.api = api;    context.result = undefined;    script.runInContext(context, {      timeout,      displayErrors: true,    });    return await context.result;  };}

Использование:

const handler = compileHandler(`  const value = await api.load(input.id);  return {    id: input.id,    value,  };`);
const result = await handler(  {    id: "item-1",  },  {    load: async id => `Loaded ${id}`,  },);console.log(result);

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

Что в итоге даёт node:vm

По сравнению с eval и new Function, модуль node:vm даёт больше контроля:

  • отдельный глобальный контекст;

  • явную передачу разрешённых объектов;

  • возможность отключить генерацию кода из строк;

  • запрет WebAssembly;

  • тайм-аут синхронного выполнения;

  • собственное имя файла для стеков ошибок;

  • возможность реализовать ограниченный require;

  • поддержку собственного модульного формата.

При этом node:vm остаётся инструментом организации исполнения, а не полноценной защитой от вредоносного JavaScript.

Мой путь от простого выполнения строки к Function-as-a-Service платформе выглядел примерно так:

eval  ↓new Function  ↓node:vm и отдельный context  ↓ограниченный require и собственный SDK  ↓отдельный процесс  ↓контейнер с лимитами CPU, памяти, сети и времени

node:vm оказался полезным промежуточным этапом. Он помог сформировать модель исполнения, интерфейс пользовательских функций, систему разрешений и передачу контекста. Но реальную изоляцию пришлось переносить на уровень отдельного процесса и контейнера.

Что в итоге

Для простого выполнения доверенного JavaScript лучше использовать new Function, а не прямой eval.

new Function не захватывает локальное окружение, позволяет явно передавать зависимости и создаёт обычную JavaScript-функцию, которую V8 способен оптимизировать. Проведённый тест показал, что динамически созданная функция была автоматически отмечена как hot and stable и скомпилирована Maglev.

Если требуется более управляемое глобальное окружение, можно перейти к node:vm, создать отдельный контекст, отключить генерацию кода из строк и передавать только разрешённые возможности.

Однако ни new Function, ни node:vm, ни ограниченный require сами по себе не обеспечивают надёжную защиту от намеренно вредоносного кода. Для запуска недоверенных программ нужна внешняя граница безопасности: отдельный процесс, контейнер или виртуальная машина с ограничениями памяти, CPU, времени выполнения, файловой системы и сети.

ссылка на оригинал статьи https://habr.com/ru/articles/1059208/