Wróć do strony głównej
Angular

Angular i Electron – Tworzenie aplikacji okienkowych z wykorzystaniem Angular

Jak już wcześniej pokazywaliśmy na łamach naszego bloga, Angular niekoniecznie musi ograniczać się tylko do aplikacji webowych (PWA, Capacitor). Dzisiaj spróbujemy uruchomić naszą aplikację bezpośrednio na desktopie, a wykorzystamy do tego framework Electron.

W tym artykule przedstawimy Wam całościowy obraz tego typu aplikacji a w kolejnej częsci przeprowadzimy Was przez proces integracji full stackowej aplikacji i pokażemy w jaki sposób można wykorzystać możliwości NestJs w środowisku Electronowym. Omówimy również szeroko poruszane aspekty bezpieczeństwa tego typu aplikacji.

Zacznijmy od omówienia samego Electrona.

  1. Czym jest Electron?
  2. Architektura
    2.1. Remote
    2.2. IPC
    2.3. ngx-electron
    2.4. Preload
    2.5. PostMessage API
    2.6. Context Bridge
    2.7. Lokalny serwer
  3. Bezpieczeństwo
    3.1. Security checklist
    3.2. Electronegativity
    3.3. Electron hardener
  4. Przykłady niepoprawnej konfiguracji
    4.1. Node Integration
    4.2. Context Isolation
    4.3. Sandboxing
  5. Aplikacja
    5.1 Remote (ngx-electron)
    5.2 IPC (ngx-electron)
    5.3 Preload
    5.4 PostMessage API
    5.5 Context Bridge
    6. Lokalny Serwer
    7. Podsumowanie
    8. Przydatne linki

Czym jest Electron?

W kilku słowach jest to framework rozwijany przez programistów z GitHuba umożliwiający tworzenie natywnych aplikacji na Windowsa, Linuxa czy też macOSa przy użyciu technologii webowych. W praktyce jest to połączenie Chromium odpowiedzialnego za uruchomienie naszej aplikacji oraz NodeJs umożliwiającego wykonywanie natywnych operacji.

Przekształcenie istniejącej aplikacji w Electronową jest dziecinnie proste i sprowadza się do kilku kroków, o których szczegółowo powiemy w kolejnym artykule poświęconym integracji.

Ze względu na charakter frameworka, po raz kolejny mamy do czynienia z wieloplatformowym programowaniem z wykorzystaniem pojedynczego, bazowego kodu.

Architektura

Aplikacje Electronowe zbudowane są w oparciu o dwie fundamentalne koncepcje. Mowa o procesach renderer, odpowiadającym poszczególnym oknom aplikacji oraz o procesie main, operującym w środowisku NodeJs i mającym dostęp do natywnych funkcjonalności.

 

Schematyczne przedstawienie architektury aplikacji Electronowej.

Żeby jednak możliwe było wykorzystanie potencjału architektury naszej aplikacji, musimy w jakiś sposób zrealizować komunikację między tymi odrębnymi procesami. Istnieje kilka dostępnych podejść.

Remote

Na początku gdy Electron jeszcze raczkował, komunikacja między rendererem a mainem odbywała się za pomocą modułu remote pozwalającego m.in. na ładowaniu modułów NodeJs bezpośrednio z poziomu aplikacji.

Rozwiązanie to było kłopotliwe ze względów bezpieczeństwa oraz wydajnościowych i zostało koniec końców oznaczone jako deprecated. Więcej informacji dlaczego tak się dzieje możecie znaleźć w artykule Jeremy’ego Rose’a.

Dokumentacja dotycząca modułu remote została całkowicie usunięta, wcześniej jednak autorzy sugerowali skorzystanie z IPC. Jak zatem powinniśmy zrealizować naszą komunikację?

IPC

Inter Process Communication to mechanizm umożliwiający komunikację między różnymi procesami w obrębie jednego systemu operacyjnego. W przypadku Electrona, wykorzystywany jest do wysyłania synchronicznych bądź asynchronicznych wiadomości pomiędzy rendererem a mainem (również w ramach samej implementacji Electrona).

Electron udostępnia nam moduły ipcMain oraz ipcRenderer za pośrednictwem których wysyłamy oraz nasłuchujemy na wiadomości. Aby jednak możliwe było bezpośrednie skorzystanie z tych modułów, aplikacja musi mieć włączoną flagę nodeIntegration.

Niestety pociąga to za sobą dość daleko idące konsekwencje, w przypadku luki w aplikacji atak XSS może nawet przerodzić się w RCE. Kwestię bezpieczeństwa związaną z tą flagą omawiamy bardziej szczegółowo w rozdziale Bezpieczeństwo.

ngx-electron

W przypadku aplikacji Angularowych mamy do dyspozycji paczkę definiującą serwisy do realizacji wspomnianej komunikacji. Paczka ta wykorzystuje pod spodem moduły IPC stąd nasza aplikacja również musi mieć włączoną flagę nodeIntegration.

Korzystając z tej paczki mamy dostęp do serwisu, za pośrednictwem którego jesteśmy w stanie wysyłać wiadomości do maina. Ponadto mamy również do dyspozycji kilka różnych API udostępnianych przez Electrona. Opis wszystkich dostępnych opcji znajdziecie w dokumentacji.

Preload

Uruchamiając naszą aplikację i tworząc jej główne okno, mamy możliwość wywołania tzw. skryptu preload. Z jego poziomu mamy dostęp zarówno do obiektu window jak i do środowiska NodeJs.

Popularną techniką było rozszerzanie obiektu window w ramach wspomnianego skryptu o dodatkowe metody umożliwiające wykonywanie operacji w środowisku NodeJs. Jest to możliwe, gdyż renderer oraz main posiadają współdzielony kontekst.

Z tego rozwiązania możemy skorzystać mając wyłączoną flagę nodeIntegration, dbając o bezpieczeństwo naszej aplikacji już na samym starcie.

W ramach konfiguracji naszego okna mamy również możliwość ustawienia flagi contextIsolation (o której będziemy mówić w dziale Bezpieczeństwo). Aby móc skorzystać z tej techniki, flaga ta musi być wyłączona.

Jak się pewnie domyślacie, tego typu rozwiązanie również jest wątpliwe pod względem bezpieczeństwa. Podobnie jak w przypadku IPC, ataki typu XSS mogą się przeobrazić w RCE, o czym ponownie więcej mówimy w dziale Bezpieczeństwo.

PostMessage API

W celu realizacji komunikacji między mainem oraz rendererem możemy również wykorzystać PostMessage API.

Jak w przypadku preload, możemy również z tego skorzystać mając wyłączoną flagę nodeIntegration.

Implementacja sprowadza się do definicji metod nasłuchujących na wiadomości w ramach skryptu preload oraz w samej aplikacji.

Niestety według dokumentacji protokół ten nie wspiera źródeł typu “file://”, stąd podczas wysyłania wiadomości musimy przekazać wartość “*” (asterix) jako parametr dla targetOrigin, co samo w sobie może mieć konsekwencje w ramach ogólnego bezpieczeństwa.

Context Bridge

Każde z powyżej przedstawionych rozwiązań wymagało od nas pewnych kompromisów dotyczących bezpieczeństwa naszej aplikacji. Dotarliśmy w końcu do rozwiązania, które na chwilę obecną jest zalecane jeśli chodzi o komunikację procesów main oraz renderer.

Jak wcześniej wspomnieliśmy, włączenie flagi contextIsolation powoduje, że obiekty window w obu procesach są różne i rozszerzanie nie ma najmniejszego sensu.

Aby jednak dalej było to możliwe, Electron udostępnia tzw. Context Bridge, za pomocą którego w bezpieczny sposób możemy definiować swego rodzaju API dla naszej aplikacji, z którego z kolei możemy korzystać w rendererze.

Lokalny serwer

Jako że podczas uruchamiania naszej aplikacji operujemy w środowisku NodeJs, nic nie stoi na przeszkodzie aby w tym momencie uruchomić lokalnie serwer, z którym nasza aplikacja będzie się komunikować za pomocą wybranego protokołu.

To rozwiązanie sprawia, że możemy skorzystać z dowolnych dostępnych frameworków do implementacji naszego backendu.

W ramach naszego artykułu, jako że Angular jest naszym ulubieńcem, skorzystamy z jego serwerowego odpowiednika czyli NestJs.

W przypadku tego rozwiązania trzeba mieć na uwadze, że serwer musi posiadać dobrze skonfigurowane własne zabezpieczenia. Możemy zatem stwierdzić, że bezpieczeństwo naszej aplikacji zależy w dużej mierze od poziomu zabezpieczeń lokalnie uruchomionego serwera.

Wszystkie z wyżej wymienionych rozwiązań stosujemy w ramach integracji naszej przykładowej aplikacji z Electronem.

Zanim jednak zaczniemy, skupmy się jeszcze przez chwilę na bezpieczeństwie aplikacji Electronowych i dlaczego jest to takie istotne.

Bezpieczeństwo

Pomimo zalet jakie niesie ze sobą wykorzystanie Electrona, bezpieczeństwo tego typu aplikacji często jest przedmiotem krytyki. Najczęściej wynika to z nieprawidłowej konfiguracji, umożliwiającej ataki typu XSS (Cross-site scripting) czy nawet RCE (Remote Code Execution).

Często drugie wymienione są możliwe ze względu na charakter architektury aplikacji Electronowych. Jako że renderer ma dostęp do maina, z jego poziomu możliwy jest również dostęp do systemu operacyjnego. Wówczas gdy atakujący w jakiś sposób uzyska możliwość wywołania złośliwego kodu po stronie renderera, może eskalować zasięg tego ataku na system ofiary.

Praktycznie każda z większych aplikacji Electronowych była przez pewien czas podatna na tego typu ataki, zaliczając do nich klienta Steam, WhatsApp, Discord, Slack, Teams czy nawet VSCode.

Świetnym źródłem wszelkich aspektów związanych z bezpieczeństwem oraz atakami jest repozytorium awesome-electronjs-hacking, zbierające w jednym miejscu różne wykłady, filmy oraz artykuły na ten temat.

 

Czy rozpoznajesz wszystkie przedstawione aplikacje Electronowe?

Kwestia zabezpieczeń jest ponadto istotna ze względu na stale rosnące liczbę aplikacji Electronowych, w chwili pisania tego artykułu dostępnych jest ich oficjalnie aż 950.

Jak zatem zaradzić wysokim wymaganiom dotyczącym standardów bezpieczeństwa?

Security checklist

Aby ograniczyć liczbę niepoprawnie skonfigurowanych aplikacji oraz podnieść ich bezpieczeństwo, programiści Electrona stworzyli listę zalecanych praktyk oraz ustawień.

Zagadnieniu bezpieczeństwa została poświęcona cała odrębna sekcja w dokumentacji, która dokładnie tłumaczy poszczególne zalecane ustawienia.

Electronegativity

W ramach zapobieganiu nieprawidłowych konfiguracji dostępne jest również narzędzie Electronegativity. Jest to paczka npm’owa, która weryfikuje naszą aplikację pod kątem zalecanych zabezpieczeń oraz udostępnia dokumentację omawiającą znalezione problemy.

Świetne narzędzie w celu przeprowadzenia weryfikacji zabezpieczeń naszej aplikacji.

Electron secure defaults

Programiści z 1Password podczas tworzenia swojej aplikacji stworzyli szablon dla bezpiecznych aplikacji Electronowych.

Jest to doskonałe miejsce aby zacząć przygodę z tworzeniem tego typu aplikacji, mając na uwadze jak najwyższy poziom zabezpieczeń.

Electron hardener

Kolejne narzędzie stworzone przez ludzi z 1Password, mające na celu wyłączenie wszelkich dodatkowych flag z jakimi może zostać uruchomiona aplikacja Electronowa, które mogłyby prowadzić do ewentualnych dziur.

Jeśli chcemy aby nasza aplikacja była postrzegana jako bezpieczna, powinniśmy zdecydowanie skorzystać z tej biblioteki.

Przykłady niepoprawnej konfiguracji

Node Integration

Jednym z głównych założeń Electrona było połączenie możliwości aplikacji webowych z NodeJs w ramach jednej aplikacji. W tym celu programiści otrzymali dostęp do środowiska natywnego z poziomu renderera.

Sam zamysł jest jak najbardziej słuszny, niestety to podejście sprawia, że jakiekolwiek luki w bezpieczeństwie mogą prowadzić do RCE. Pojawiła się zatem możliwość wyłączenia bezpośredniej komunikacji między rendererem a mainem.

Mowa o fladze nodeIntegration, którą możemy ustawić podczas tworzenia okna. Od wersji v5 jest ona domyślnie wyłączona dla nowo utworzonych widoków.

Pojawia się natomiast kwestia w jaki sposób Electron ma spełniać funkcję desktopowych aplikacji nie mając dostępu do środowiska NodeJs? Jak już wcześniej wspominaliśmy, od tego czasu pojawiło się wiele alternatywnych podejść, które umożliwiają komunikację między procesami w bezpieczny sposób, np. Context Bridge,

Przykładowe wykorzystanie luki związanej z tą flagą: aplikacja Notable.

Context Isolation

Skrypt preload o którym wcześniej mówiliśmy oraz proces renderer przez długi czas domyślnie współdzieliły kontekst, tzn. przykładowo zmiany w obiekcie window w obrębie jednego procesu były widoczne w drugim.

Electron udostępnia nam flagę contextIsolation, za pomocą której możemy zlikwidować wspomnianą funkcjonalność, a od wersji v12 jest ona domyślnie włączona. Gdy Context Isolation jest aktywne, każdy proces renderer oraz skrypt preload posiadają własne kopie obiektów window, document itp. na których operują.

Współdzielenie kontekstu pomiędzy wspomnianymi procesami mogło być wykorzystane do przeprowadzenia udanych ataków XSS poprzez np. nadpisywanie prototypów.

Przykład wykorzystania luki: aplikacja Discord lub aplikacja WireApp.

Sandboxing

Jako że Electron wykorzystuje Chromium do renderowania aplikacji webowej, istnieje możliwość skorzystania dodatkowych zabezpieczeń zawartych w samym Chromium.

Jednym z nich jest sandboxing, czyli ograniczenie uprawnień danego procesu tak, aby nawet w przypadku udanego ataku, atakujący nie był w stanie eskalować swoich uprawnień i wywołać szkód w systemie ofiary.

Od wersji v5 flaga sandbox jest domyślnie włączona.

Integracja z Electronem

Podczas omawiania komunikacji między rendererem a mainem przywołaliśmy kilka możliwych sposobów na jej realizację, niektóre mniej a niektóre bardziej zalecane.

Ze względów dydaktycznych, naszą aplikację zintegrujemy z Electronem realizując wspomnianą komunikację na każdy z możliwych sposobów. Całość dostępna jest w publicznym repozytorium.

Aplikacja

W ramach integracji stworzymy przykładową aplikację wykorzystującą w jakiś sposób natywne funkcjonalności jakie udostępnia nam Electron.

W celach demonstracyjnych zaimplementujemy możliwość wyświetlenia natywnego dialogu oraz natywnych notyfikacji informujących o rzekomych obliczeniach wykonywanych po stronie serwera.

Jeśli chodzi o Electronową konfigurację skorzystamy z wspomnianego wcześniej szablonu electron-secure-defaults.

Nasz aplikacja prezentuje się następująco:

Poglądowa aplikacja Electronowa wykorzystująca natywne funkcje danej platformy.

Jako że mamy już gotowy szkielet naszej aplikacji, nadszedł czas na integrację z jej serwerową częścią.

Remote (ngx-electron)

Na pierwszy ogień idzie nie zalecany już aktualnie moduł @electron/remote, dający nam bezpośredni dostęp do maina z poziomu renderera. Jak pamiętacie z wcześniejszych rozdziałów, tego typu furtka może mieć poważne konsekwencje jeśli chodzi o bezpieczeństwo.

Pokażemy Wam jak coś takiego skonfigurować, oczywiście nie zalecamy stosowania tego podejścia w praktyce.

Wykorzystamy w tym celu paczki ngx-electron oraz @electron/remote. Wykonujemy polecenie:

Następnie moduł remote musimy zainicjalizować, wywołując odpowiednią metodę w ramach inicjalizacji naszej aplikacji, w pliku main.ts.

Aby była możliwość wykorzystania tego modułu, musimy również znacząco obniżyć poziom zabezpieczeń naszego okna ustawiając wybrane flagi na:

  • nodeIntegration: true
  • contextIsolation: false
  • enableRemoteModule: true
  • sandbox: false

Ponadto musimy wyłączyć wywołanie app.enableSandbox().

Po wstępnej konfiguracji ngx-electron, co sprowadza się do zaimportowania modułu oraz wstrzyknięcia serwisu, możemy skorzystać z omawianego modułu remote i wywołać natywne funkcjonalności z poziomu naszej aplikacji.

Kod serwisu odpowiedzialnego za wywoływanie wspomnianych funkcji wygląda następująco:

Serwis wykorzystujący moduł remote w celu wyświetlania dialogu oraz notyfikacji.

IPC (ngx-electron)

Przechodzimy do wykorzystania IPC w celu komunikacji między aplikacją a naszym backendem. Skorzystamy tutaj ponownie z paczki ngx-electron oraz z kontrolera NestJs.

Instalujemy najpierw naszą paczkę:

Ponownie abyśmy bezpośrednio mogli wykorzystać ipcMain oraz ipcRenderer musimy odpowiednio ustawić flagi związane z bezpieczeństwem:

  • nodeIntegration: true
  • contextIsolation: false
  • sandbox: false

Musimy również wyłączyć wywołanie app.enableSandbox().

Przechodząc do implementacji naszego backendu w NestJs, definiujemy nasz kontroler:

Kontroler obsługujący konkretne wiadomości.

Obsługa okien dialogowych oraz notyfikacji znajduje się w serwisie AppService. Implementacja wygląda analogicznie jak w przypadku @electron/remote z tą różnicą, że operujemy bezpośrednio na modułach Electrona.

Od strony aplikacji implementacja sprowadza się ponownie do wykorzystania serwisu udostępnionego nam przez paczkę ngx-electron, tym razem jednak odwołujemy się do ipcRenderer zamiast do remote.

Serwis odpowiadający za wysyłanie wiadomości do backendu.

Rozwiązanie to wydaje się bezpieczniejsze, w dalszym jednak ciągu atakujący może uzyskać nieautoryzowany dostęp do systemu użytkownika za sprawą wyłączenia niektórych zabezpieczeń.

Preload

Teraz zajmiemy się integracją za pomocą skryptu preload. W odróżnieniu od powyższych, aby to rozwiązanie było możliwe, wystarczy że dezaktywujemy pojedynczą flagę odpowiadającą za izolację kontekstu:

  • contextIsolation: false

Wewnątrz samego skryptu definiujemy API, które będzie dostępne w współdzielonym obiekcie window w naszej aplikacji.

Definicja skryptu preload rozszerzającego wspólny obiekt window.

Pozostało nam nic innego jak wywoływać odpowiednie metody w naszym serwisie:

Definicja serwisu wywołującego metody na obiekcie window.

Aby pozostać w zgodzie z naszym sumieniem, utworzone zostały interfejsy reprezentujące “nowy”, rozszerzony obiekt window.

Interfejsy typujące nowy obiekt window.

Jeśli chodzi natomiast o kontroler po stronie naszego backendu, wszystko pozostaje bez zmian.

W tym rozwiązaniu skorzystaliśmy z zalecanego rozwiązania jeśli chodzi o komunikację między procesami Electronowymi. W dalszym jednak ciągu nasza aplikacja nie jest całkowicie odporna na podatności.

PostMessage API

Alternatywnym sposobem na wykorzystanie skryptu preload jest użycie PostMessage API. Wówczas zachowujemy domyślną konfigurację naszej aplikacji Electronowej, zalecanej przez electron-secure-defaults.

Przechodząc bezpośrednio do implementacji, sprowadza się ona do modyfikacji skryptu preload:

Implementacja skryptu preload z wykorzystaniem PostMessage API.

Oraz serwisu wysyłającego wiadomości:

Serwis wysyłający wiadomości do maina za pośrednictwem PostMessage API.

Wadą tego rozwiązania jest to, że nie jesteśmy w stanie przeprowadzić komunikacji w sposób synchroniczny. Ponadto, podczas wysyłania wiadomości musimy podać wartość * jako parametr targetOrigin, co samo w sobie może powodować luki w bezpieczeństwie.

Przejdźmy zatem do rozwiązania za pomocą którego nie musimy iść na żadne kompromisy w kwestii bezpieczeństwa oraz które zapewnia nam również synchroniczną komunikację.

Context Bridge

Rekomendowanym podejściem jest wykorzystanie tzw. Context Bridge’a w ramach skryptu preload w celu zapewnienia komunikacji między procesami Electronowymi.

Implementacja ponownie sprowadza się do odpowiedniej definicji API dla naszej aplikacji w ramach skryptu preload, z tą różnicą że nie rozszerzamy bezpośrednio obiektu window ale wykorzystujemy wspomniany wcześniej Context Bridge.

Definicja API przy użyciu Context Bridge.

Serwis umożliwiający nam komunikację wygląda identycznie jak w przypadku bezpośredniego rozszerzania obiektu window.

Rozwiązanie to jest aktualnie jednym z najbezpieczniejszych sposobów realizacji omawianej komunikacji. Istnieje natomiast jeszcze jedno podejście, które jest bardziej elastyczne i polega na całkowitym porzuceniu komunikacji przy użyciu IPC.

Lokalny serwer

Ostatnim dostępnym rozwiązaniem jest postawienie lokalnego serwera. Wówczas z poziomu naszej aplikacji komunikacja odbywa się za pomocą wybranego protokołu tak jakby to było w przypadku zdalnego serwera.

W tym rozwiązaniu mamy dowolność jeśli chodzi o wybór technologii, istotne natomiast jest to, aby wziąć pod uwagę solidne zabezpieczenie naszego serwera.

Jeśli chodzi o technologię oczywiście korzystamy z NestJs. Skonfigurujmy zatem nasz kontroler:

Kontroler definiujący API.

Implementacja serwisu pozostaje praktycznie taka sama oprócz minimalnych zmian w sygnaturach funkcji (brak eventu związanego z IPC).

Od strony aplikacji natomiast po prostu wysyłamy żądania pod wybrany adres:

Serwis wysyłający żądania do lokalnego serwera.

Niewątpliwymi zaletami tego rozwiązania jest elastyczność w doborze technologii naszego backendu jak i możliwość łatwej ewentualnej jego podmiany. Jak wielokrotnie wspominaliśmy, istotne jest odpowiednie zabezpieczenie naszego serwera.

Podsumowanie

Integracja istniejącej aplikacji z Electronem jest stosunkowa prosta, musimy być jednak świadomi że istnieją potencjalne luki w bezpieczeństwie takich aplikacji. Stosując jednak wszystkie zalecane zabezpieczenia, nasza aplikacja z pewnością będzie bezpieczna.

Kod źródłowy naszej przykładowej aplikacji znajdziecie w repozytorium, a przełączając się między branchami możecie zweryfikować jak sprawdza się dane rozwiązanie.

Mamy zatem nadzieję, że zastosujecie zdobytą tutaj wiedzę w praktyce i stworzycie nowe, bezpieczne aplikacje Electronowe z użyciem Angulara.

Przydatne linki

  1. Electron remote module considered harmful, https://nornagon.medium.com/electrons-remote-module-considered-harmful-70d69500f31
  2. Awesome ElectronJs hacking repository, https://github.com/doyensec/awesome-electronjs-hacking
  3. Electronegativity, https://github.com/doyensec/electronegativity
  4. Electron secure defaults, https://github.com/1Password/electron-secure-defaults
  5. Security checklist, https://www.electronjs.org/docs/tutorial/security#checklist-security-recommendations
  6. Sandbox, https://www.electronjs.org/docs/tutorial/sandbox
  7. Session, https://www.electronjs.org/docs/api/session
  8. Electron IPC and NodeIntegration, https://stackoverflow.com/questions/52236641/electron-ipc-and-nodeintegration

O autorze

Marcin Leśniczek

Wiecznie głodny wiedzy z pasją dla aplikacji mobilnych oraz hybrydowych. Zawsze otwarty na nowe pomysły i technologie, szukający dziury w całym. Po godzinach entuzjasta nauki i astronomii.

Zapisz się do naszego newslettera. Bądź na bieżąco z najnowszymi trendami, poradami, meetupami i stań się częścią społeczności Angulara w Polsce. Rynek pracy docenia członków społeczności.

Dodaj komentarz

Twój adres e-mail nie zostanie opublikowany. Wymagane pola są oznaczone *