Ostatnio natrafiłem na projekt Hagana, który oferuje ochronę w trakcie wykonywania skryptu Node.js. Polega ona na blokowaniu operacji sieciowych oraz operacji na plikach poza katalogiem projektu. Postanowiłem zatem sprawdzić, jak to dokładnie działa pod spodem.

Proxy, czyli stary znajomy

Myślałem, że w środku znajdę coś naprawdę ciekawego… i trochę się zawiodłem. Zobaczyłem bowiem rzecz, którą bawię się od dłuższego czasu i którą już nieraz opisywałem tutaj na blogu. Jedyna różnica polega na tym, że Hagana wykorzystuje ją w innych celach.

A o jakiej rzeczy tak w zasadzie mówię? O proxy, przy pomocy którego nie tak dawno odtwarzałem wycinek Immera. Okazuje się, że ten mechanizm można także wykorzystać, żeby lepiej zabezpieczyć swoją aplikację w Node.js. Trzonem w Haganie są bowiem tzw. overrides – funkcje nadpisujące natywne mechanizmy Node’a. Nadpisaniu ulegają m.in. funkcje odpowiedzialne za odczyt i zapis do plików. Każda taka funkcja jest opakowywana w proxy, które przy pomocy pułapki apply wykonuje dodatkową walidację przed faktyczną operacją na plikach. Dzięki temu możliwe jest zabronienie operacji na plikach, które znajdują się poza katalogiem projektu (czyli nici z podkradania /etc/passwd…).

Funkcje odpowiadające za nadpisanie poszczególnych mechanizmów (obsługi plików, wątków roboczych itd.) są odpalane w chwili importu Hagany do naszej aplikacji. I to w sumie tyle – nie ma tutaj nic przesadnie skomplikowanego, a działa to całkiem dobrze.

ESM, czyli zaczynają się problemy

Niemniej pierwsze, co rzuciło mi się w oczy, to fakt, że Hagana była raczej tworzona z myślą o modułach CJS (czyli tych starych, z require()). Intuicja podpowiadała mi, że sposób nadpisywania poszczególnych funkcji nie będzie działał (lub będzie działał częściowo) w przypadku nowych modułów (czyli tych z import).

Najłatwiej wyjaśnić to na prostym przykładzie. Wyobraźmy sobie, że mamy plik fs.mjs, który imituje natywny moduł fs z Node.js. Dla uproszczenia będzie on zawierał tylko jedną funkcję, readFile(). W przypadku natywnego modułu fs funkcja taka dostępna byłaby na co najmniej dwa sposoby:

  1. jako własność domyślnego eksportu:

     import fs from 'node:fs';
    
     fs.readFile( 'jakaś/ścieżka', callback );
    
  2. jako osobny import:

     import { readFile } from 'node:fs';
    
     readFile( 'jakaś/ścieżka', callback );
    

Stwórzmy zatem moduł, który by działał w taki sposób:

function readFile() {} // 1

const fs = { // 2
	readFile // 3
};

export default fs; // 4
export { readFile }; // 5

Na początku tworzymy funkcję readFile() (1). Następnie tworzymy obiekt fs (2), w którym tworzymy własność readFile zawierającą naszą funkcję (3). Ten obiekt będzie naszym domyślnym eksportem (4). Oprócz tego eksportujemy osobno samą funkcję readFile() (5).

Stwórzmy jeszcze moduł, który będzie próbował nadpisać naszą funkcję readFile():

import fs, { readFile } from './fs.mjs'; // 1

console.log( fs.readFile === readFile ); // true – 2

fs.readFile = () => {}; // 3

console.log( fs.readFile === readFile ); // false – 4

readFile = () => {}; // error – 5

Na samym początku importujemy zarówno domyślny eksport, jak i bezpośrednio funkcję readFile() (1). Sprawdzamy sobie, czy fs#readFile() to ta sama funkcja co readFile() (2). I faktycznie, jest to ta sama funkcja. Następnie nadpisujemy fs#readFile() nową funkcją (3) i ponawiamy sprawdzenie (4). Tym razem otrzymujemy już fałsz. Natomiast próba nadpisania bezpośrednio readFile() (5) kończy się błędem.

A to dlatego, że w uproszczeniu importy można traktować jako zmienne zadeklarowane przy pomocy const. Z tego też powodu można mutować zaimportowany obiekt fs, ale już nie można nadpisać importowanych wartości. Innymi słowy: w przypadku ESM użytkownik będzie zabezpieczony wyłącznie, gdy będzie używał fs#readFile(), ale gdy zaimportuje readFile() bezpośrednio, to ominie całe nasze zabezpieczenie. A to brzmi jak naprawdę poważny problem.

Czemu jednak działa to w CJS? Ponieważ tam eksportowany jest po prostu obiekt z poszczególnymi funkcjami. Nawet zrobienie

const { readFile } = require( 'node:fs' );

oznacza jedynie destrukturyzację tego obiektu. ESM są pod tym względem, niestety, bardziej kłopotliwe.

Bramkarz, czyli próba obejścia

Zacząłem się zastanawiać, czy jest jakiś sposób, aby być w stanie nadpisać obydwa sposoby importowania funkcji obsługujących pliki w przypadku, gdy aplikacja korzysta z natywnej obsługi ESM w Node.js. Jedyną rzeczą, jaka przychodziła mi do głowy, były niestandardowe loadery. Ich zamysł opisałem już kiedyś na blogu w artykule poświęconym wczytywaniu plików .html bezpośrednio w Node.js. Co prawda, od tamtego czasu dość znacząco zmieniła się składnia rozwiązania, ale podstawy teoretyczne pozostały te same: dzięki niestandardowym loaderom możemy podstawić dowolny kod w miejsce konkretnego modułu.

Taki niestandardowy loader zazwyczaj składa się z dwóch zasadniczych części – asynchronicznej funkcji resolve(), która przerabia nazwy modułów (takie jak node:fs czy @comandeer/rollup-lib-bundler) na URL-e, oraz asynchronicznej funkcji load(), która zajmuje się faktycznym wczytywaniem modułów. Całość jest stosunkowo prosta i w zupełności wystarczająca do wykonania tego, co chciałem.

Zamysł był prosty: funkcja resolve() będzie otrzymywać informacje o tym, kiedy program próbuje załadować moduł node:fs i na ich podstawie będzie decydować, czy należy wczytać nasz podmieniony moduł. Loader, oprócz samej nazwy/URL-a modułu, dostaje także dodatkowe informacje, w tym o rodzicu danego modułu – czyli o module, który wczytał aktualnie importowany moduł. Postanowiłem to wykorzystać. W chwili, gdy program chciał wczytać moduł node:fs, podmieniałem go na adres tego nadpisującego. Oczywiście wewnątrz tego nadpisującego modułu musiałem importować oryginalne node:fs. Dzięki informacji o rodzicu modułu mogłem ominąć problem wywołania nieskończonej pętli importów. Nie podmieniałem bowiem node:fs w momencie, gdy był importowany ze środka mojego nadpisującego modułu.

Podsumowując:

  1. Funkcja resolve() sprawdza, czy program nie próbuje importować node:fs.
  2. Jeśli tak, sprawdzany jest rodzic modułu i jeśli jest inny od mojego nadpisującego modułu (dla ułatwienia nazwijmy go bramkarz:fs), URL node:fs podmieniany jest na URL modułu bramkarz:fs.
  3. Moduł bramkarz:fs jest wczytywany przez domyślny loader Node’a.
  4. Moduł bramkarz:fs wczytuje oryginalny moduł node:fs.
  5. Funkcja resolve() ignoruje ten import, bo widzi, że rodzicem jest bramkarz:fs.
  6. Mooduł bramkarz:fs zwraca funkcje operujące na plikach, które są opakowane w proxy sprawdzające, czy podana ścieżka do pliku powinna być dostępna dla programu.

Brzmi to dość skomplikowanie i na pierwszy rzut oka jest nieco przekombinowane, ale na tę chwilę nie przychodzi mi do głowy żaden inny sposób, w jaki można by to rozwiązać.

A skoro już siadłem do zabawy nad tym, stwierdziłem, że może spróbuję napisać jakieś sensowne narzędzie. Tak narodził się Bramkarz. Co prawda jest na bardzo wczesnym etapie rozwoju, ale można już podpatrzeć w nim niestandardowy loader, o którym przed chwilą pisałem. Dodatkowo doszedłem do wniosku, że bardziej przyjazne użytkownikowi będzie stworzenie prostego programu konsolowego, który się będzie odpalać zamiast Node’a (czyli bramkarz . zamiast node .). On już zadba o to, by loader był dołączany do uruchamianego programu i wykonywał w tle swoją robotę, niemalże niezauważalnie dla użytkownika. Natomiast sama konfiguracja została wyrzucona do osobnego pliku, .bramkarzrc.json.

ESM, czyli moje problemy mają problemy

Chyba największym problemem (czy też – upierdliwością) jest mocno statyczna składnia ESM. W idealnym świecie nadpisanie modułu wyglądałoby tak:

import * as fs from 'node:fs'; // 1

const newFS = { ...fs }; // 2

newFS.readFile = newFS.default.readFile = createProxy(); // 3

export { ...newFS }; // 4

Na początku importuję sobie nasz moduł (1). Następnie tworzę jego kopię (2) i nadpisuję w niej interesujące mnie rzeczy (3). Następnie używam destrukturyzacji w eksporcie, żeby wyeksportować wszystko, co jest w newFS (4).

Innymi słowy zakładałbym, że destrukturyzacja w eksporcie robiłaby to samo co kod poniżej:

export { fs.default as default };
export { fs.readFile as readFile };
// itd.

W rzeczywistości ani pierwszy, ani drugi kod nie działa… Jestem więc skazany na rzeźbienie wszystkich eksportów ręcznie:

import * as fs from 'node:fs';

const newFS = { ...fs };

newFS.readFile = newFS.default.readFile = createProxy();

const { readFile } = newFS;

export default newFS.default;
export { readFile };

Tak na razie wygląda nadpisanie fs w Bramkarzu. Powyższy kod tworzy tak naprawdę moduł jedynie z metodą readFile() oraz domyślnym eksportem. Reszta pojedynczych eksportów jest stracona, dopóki nie będę miał czasu i chęci, by je na powrót dodać… A że eksporty mogą być tylko na najwyższym poziomie, bez żadnego zagnieżdżenia, to odpada nawet pętla for...of do przeiterowania po newFS i stworzenia poszczególnych eksportów. To naprawdę trzeba zrobić wszystko ręcznie.

Rozwój, czyli co dalej?

Nie wiem, czy będę próbował rozwijać Bramkarza i czy w ogóle wypuszczę jakąkolwiek wersję na npm-ie. Na ten moment mnie mocno zmęczył, głównie ze względu na to, jak mało przyjazne wydają się narzędzia do pracy z natywnymi ESM w Node w porównaniu do modułów CJS, które dzięki transpilatorom i tak mogą wyglądać jak ESM.

Z drugiej strony podejście Bramkarza wydaje się nieco bezpieczniejsze niż Hagany. Loader, który działa w tle, niezależnie od programu, który ma chronić, brzmi jak coś bardziej odpornego na próby przechytrzenia, niż nadpisywanie modułów dopiero w chwili importu zabezpieczającego modułu. Dodatkowo jest to bardziej przezroczyste dla użytkownika, który po prostu odpala program i ten jest chroniony z automatu.

Niemniej sam pomysł takiego zabezpieczenia mi się podoba. Zdecydowanie brakuje czegoś takiego wbudowanego w Node.js. Deno robi to zdecydowanie lepiej. Może kiedyś dochrapiemy się tego także w Node.js. Na ten jednak moment rozwiązania pokroju Hagany czy właśnie Bramkarza wydają się wartą rozważenia namiastką.