Używanie eval() w elementach iframe umieszczonych w piaskownicy

System rozszerzeń Chrome wymusza dość rygorystyczną domyślną politykę bezpieczeństwa treści (CSP). Ograniczenia wynikające z zasad są proste: skrypt musi zostać przeniesiony poza wiersz do osobnych plików JavaScript, wbudowane moduły obsługi zdarzeń trzeba przekonwertować na korzystanie z elementu addEventListener, a element eval() musi być wyłączony.

Zdajemy sobie jednak sprawę, że różne biblioteki korzystają z konstrukcji typu eval() i eval, takich jak new Function(), które ułatwiają optymalizację wydajności i korzystanie z nich. Szczególnie narażone są biblioteki szablonów na taki sposób implementacji. Niektóre z nich (np. Angular.js) od razu po uruchomieniu obsługują CSP, ale wiele popularnych platform nie zostało jeszcze zaktualizowanych do mechanizmu kompatybilnego ze światem dostępnym tylko w eval rozszerzeniach. Usunięcie obsługi tej funkcji okazało się więc większym problemem dla deweloperów niż się spodziewaliśmy.

W tym dokumencie opisujemy piaskownicę jako bezpieczny mechanizm pozwalający uwzględnić te biblioteki w projektach bez naruszania bezpieczeństwa.

Dlaczego piaskownica?

Element eval jest niebezpieczny w rozszerzeniu, ponieważ uruchamiany przez niego kod ma dostęp do wszystkiego w środowisku o wysokich uprawnieniach rozszerzenia. Dostępnych jest wiele zaawansowanych interfejsów API chrome.*, które mogą negatywnie wpłynąć na bezpieczeństwo i prywatność użytkowników. Nie musisz się martwić o proste wydobycie danych. Oferowane rozwiązanie to piaskownica, w której eval może uruchamiać kod bez dostępu do danych rozszerzenia ani do jego wysokiej wartości API. Nie ma danych, interfejsów API ani problemów.

W tym celu pokazujemy określone pliki HTML w pakiecie rozszerzeń jako pliki w piaskownicy. Po każdym załadowaniu strony w piaskownicy jest ona przenoszona do unikalnego źródła i nie otrzymuje dostępu do interfejsów API chrome.*. Jeśli wczytamy stronę znajdującą się w piaskownicy do naszego rozszerzenia za pomocą obiektu iframe, możemy przekazać do niej komunikaty, pozwolić mu na jakąś czynność związaną z tymi wiadomościami i zaczekać, aż zwróci wynik. Ten prosty mechanizm komunikacji daje nam wszystko, czego potrzebujemy, aby bezpiecznie umieścić w procesie rozszerzenia kod oparty na eval.

Tworzenie piaskownicy i korzystanie z niej

Jeśli chcesz od razu przejść do kodu, pobierz przykładowe rozszerzenie piaskownicy i zacznij działać. To działający przykład małego interfejsu API do przesyłania wiadomości, który jest oparty na bibliotece szablonów Handlebars i powinno zawierać wszystko, czego potrzebujesz. Jeśli szukasz dokładniejszych wyjaśnień, omówię ten fragment.

Wyświetlenie listy plików w pliku manifestu

Każdy plik, który powinien być uruchomiony wewnątrz piaskownicy, musi być wymieniony w pliku manifestu rozszerzeń przez dodanie właściwości sandbox. Jest to kluczowy krok, który łatwo jest zapomnieć. Dlatego sprawdź dokładnie, czy plik piaskownicy znajduje się w pliku manifestu. W tym przykładzie dzielimy na piaskownice plik o nazwie „sandbox.html”. Wpis w pliku manifestu wygląda tak:

{
  ...,
  "sandbox": {
     "pages": ["sandbox.html"]
  },
  ...
}

Wczytaj plik piaskownicy

Aby można było zrobić coś ciekawego z plikiem w trybie piaskownicy, musimy go załadować w kontekście, w którym można mu to umożliwić za pomocą kodu rozszerzenia. W tym przypadku plik sandbox.html został wczytany na stronę rozszerzenia przez iframe. Plik JavaScript strony zawiera kod, który po każdym kliknięciu działania przeglądarki wysyła wiadomość do piaskownicy. Aby to zrobić, znajduje obiekt iframe na stronie i wywołuje metodę postMessage() za pomocą jej contentWindow. Wiadomość jest obiektem zawierającym 3 właściwości: context, templateName i command. Za chwilę omówimy context i command.

service-worker.js:

chrome.action.onClicked.addListener(() => {
  chrome.tabs.create({
    url: 'mainpage.html'
  });
  console.log('Opened a tab with a sandboxed page!');
});

extension-page.js:

let counter = 0;
document.addEventListener('DOMContentLoaded', () => {
  document.getElementById('reset').addEventListener('click', function () {
    counter = 0;
    document.querySelector('#result').innerHTML = '';
  });

  document.getElementById('sendMessage').addEventListener('click', function () {
    counter++;
    let message = {
      command: 'render',
      templateName: 'sample-template-' + counter,
      context: { counter: counter }
    };
    document.getElementById('theFrame').contentWindow.postMessage(message, '*');
  });

Zrób coś niebezpiecznego

Po wczytaniu elementu sandbox.html wczytuje się bibliotekę Handlebars, a następnie tworzy i kompiluje wbudowany szablon w sposób sugerujący, że:

extension-page.html:

<!DOCTYPE html>
<html>
  <head>
    <script src="mainpage.js"></script>
    <link href="styles/main.css" rel="stylesheet" />
  </head>
  <body>
    <div id="buttons">
      <button id="sendMessage">Click me</button>
      <button id="reset">Reset counter</button>
    </div>

    <div id="result"></div>

    <iframe id="theFrame" src="sandbox.html" style="display: none"></iframe>
  </body>
</html>

sandbox.html:

   <script id="sample-template-1" type="text/x-handlebars-template">
      <div class='entry'>
        <h1>Hello</h1>
        <p>This is a Handlebar template compiled inside a hidden sandboxed
          iframe.</p>
        <p>The counter parameter from postMessage() (outer frame) is:
          </p>
      </div>
    </script>

    <script id="sample-template-2" type="text/x-handlebars-template">
      <div class='entry'>
        <h1>Welcome back</h1>
        <p>This is another Handlebar template compiled inside a hidden sandboxed
          iframe.</p>
        <p>The counter parameter from postMessage() (outer frame) is:
          </p>
      </div>
    </script>

To nie koniec! Chociaż Handlebars.compile kończy się używaniem new Function, wszystko działa dokładnie zgodnie z oczekiwaniami – w efekcie otrzymujemy skompilowany szablon w templates['hello'].

Przekaż wynik z powrotem

Udostępnimy ten szablon za pomocą odbiornika, który akceptuje polecenia ze strony rozszerzenia. Użyjemy przekazanego pliku command, aby określić, co należy zrobić (wyobrażasz sobie coś więcej niż tylko renderowanie; np. tworzenie szablonów?). Być może jako zarządzanie nimi?), a element context będzie przekazywany bezpośrednio do szablonu na potrzeby renderowania. Wyrenderowany kod HTML zostanie zwrócony na stronę rozszerzenia, dzięki czemu później będzie ono mogło:

 <script>
      const templatesElements = document.querySelectorAll(
        "script[type='text/x-handlebars-template']"
      );
      let templates = {},
        source,
        name;

      // precompile all templates in this page
      for (let i = 0; i < templatesElements.length; i++) {
        source = templatesElements[i].innerHTML;
        name = templatesElements[i].id;
        templates[name] = Handlebars.compile(source);
      }

      // Set up message event handler:
      window.addEventListener('message', function (event) {
        const command = event.data.command;
        const template = templates[event.data.templateName];
        let result = 'invalid request';

       // if we don't know the templateName requested, return an error message
        if (template) {
          switch (command) {
            case 'render':
              result = template(event.data.context);
              break;
            // you could even do dynamic compilation, by accepting a command
            // to compile a new template instead of using static ones, for example:
            // case 'new':
            //   template = Handlebars.compile(event.data.templateSource);
            //   result = template(event.data.context);
            //   break;
              }
        } else {
            result = 'Unknown template: ' + event.data.templateName;
        }
        event.source.postMessage({ result: result }, event.origin);
      });
    </script>

Po powrocie na stronę rozszerzenia otrzymamy tę wiadomość i zrobimy coś interesującego z przesłanymi danymi html. W tym przypadku odczytamy go w powiadomieniu, ale jest też możliwe bezpieczne użycie kodu HTML jako części interfejsu rozszerzenia. Wstawienie go za pomocą innerHTML nie stwarza istotnego zagrożenia dla bezpieczeństwa, ponieważ uważamy treści wyrenderowane w piaskownicy za zaufane.

Ten mechanizm ułatwia tworzenie szablonów, ale oczywiście nie ogranicza się do nich. Każdy kod, który nie działa od razu w ramach ścisłej polityki bezpieczeństwa treści, może znajdować się w piaskownicy. W rzeczywistości takie rozwiązanie często sprawdza się w piaskownicy komponentów rozszerzeń, które były prawidłowo uruchamiane. Dzięki temu każdy element programu ma jak najmniejszy zestaw uprawnień niezbędnych do jego prawidłowego wykonania. Prezentacja na temat pisania bezpiecznych aplikacji internetowych i rozszerzeń do Chrome z Google I/O 2012 zawiera kilka przykładów tych technik w praktyce i warto poświęcić na nie 56 minut.