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

System rozszerzeń Chrome wymusza dość rygorystyczne domyślne zabezpieczenia Content Security Policy (CSP). Ograniczenia w zasadach są proste: skrypt musi zostać przeniesiony do osobnych plików JavaScript, a obsługa zdarzeń w wierszu kodu musi zostać przekonwertowana na obsługę za pomocą funkcji addEventListener. Funkcja eval() jest wyłączona.

Zdajemy sobie jednak sprawę, że wiele bibliotek używa konstrukcji podobnych do eval()eval, takich jak new Function(), w celu optymalizacji wydajności i ułatwienia wyrażania. Biblioteki szablonów są szczególnie podatne na ten styl implementacji. Chociaż niektóre z nich (np. Angular.js) obsługują CSP, wiele popularnych frameworków nie zostało jeszcze zaktualizowanych do mechanizmu zgodnego z modelem bez rozszerzeń.eval Usunięcie obsługi tej funkcji okazało się bardziej problematyczne niż oczekiwano dla deweloperów.

W tym dokumencie przedstawiamy piaskownicę jako bezpieczny mechanizm umożliwiający uwzględnianie tych bibliotek w projektach bez obniżania poziomu bezpieczeństwa.

Dlaczego piaskownica?

eval jest niebezpieczny w rozszerzeniu, ponieważ kod, który wykonuje, ma dostęp do wszystkiego w środowisku o wysokich uprawnieniach rozszerzenia. Dostępnych jest wiele potężnych interfejsów API chrome.*, które mogą poważnie wpłynąć na bezpieczeństwo i prywatność użytkowników. Proste wydobywanie danych to najmniejszy z naszych problemów. Oferowane rozwiązanie to piaskownica, w której eval może wykonywać kod bez dostępu do danych rozszerzenia ani do interfejsów API o wysokiej wartości. Brak danych, brak interfejsów API, brak problemów.

Osiągamy to, umieszczając w pakiecie rozszerzenia listę określonych plików HTML, które mają być umieszczone w piaskownicy. Gdy wczytasz stronę w piaskownicy, zostanie ona przeniesiona do niepowtarzalnego źródła i nie będzie mieć dostępu do interfejsów API chrome.*. Jeśli wczytamy tę stronę piaskownicy w naszym rozszerzeniu za pomocą iframe, możemy przekazać mu wiadomości, pozwolić mu na działanie na podstawie tych wiadomości i poczekać, aż przekaże nam wynik. Ten prosty mechanizm przesyłania wiadomości zapewnia nam wszystko, czego potrzebujemy, aby bezpiecznie uwzględnić kod generowany przez eval w przepływie pracy rozszerzenia.

Tworzenie piaskownicy i korzystanie z niej

Jeśli chcesz od razu zabrać się za kodowanie, pobierz przykładowe rozszerzenie z sandboxem i spróbuj. To działający przykład interfejsu API do obsługi wiadomości, który został stworzony na podstawie biblioteki szablonów Handlebars. Powinien on zawierać wszystko, czego potrzebujesz do rozpoczęcia pracy. Jeśli chcesz dowiedzieć się więcej, przyjrzyjmy się temu przykładowi.

Wyświetlanie listy plików w pliku manifestu

Każdy plik, który powinien być uruchamiany w piaskownicy, musi być wymieniony w pliku manifestu rozszerzenia przez dodanie właściwości sandbox. To kluczowy krok, o którym łatwo zapomnieć, dlatego sprawdź, czy plik z piaskownicy jest wymieniony w pliku manifestu. W tym przykładzie umieszczamy w piaskownicy plik o nazwie „sandbox.html”. Wpis w pliku manifestu wygląda tak:

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

Załaduj plik z piaskownicy

Aby móc wykonać interesującą czynność na pliku w piaskownicy, musimy go załadować w kontekście, w którym kod rozszerzenia może go obsłużyć. W tym przypadku plik sandbox.html został załadowany na stronę rozszerzenia za pomocą iframe. Plik JavaScript strony zawiera kod, który wysyła wiadomość do piaskownicy, gdy użytkownik kliknie działanie przeglądarki. Aby to zrobić, kod wyszukuje na stronie element iframe i wywołuje metodę postMessage() elementu contentWindow. Wiadomość to obiekt zawierający 3 właściwości: context, templateNamecommand. Za chwilę przyjrzymy się contextcommand.

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, '*');
  });

robisz coś niebezpiecznego;

Gdy sandbox.html jest wczytywana, wczytuje bibliotekę Handlebars i tworzy oraz kompiluje szablon inline w sposób sugerowany przez Handlebars:

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 zadziała. Mimo że Handlebars.compile korzysta z new Function, wszystko działa zgodnie z oczekiwaniami i ostatecznie otrzymujemy skompilowany szablon w templates['hello'].

Przekazywanie wyniku z powrotem

Udostępnimy ten szablon, konfigurując odbiornik wiadomości, który będzie przyjmować polecenia ze strony rozszerzenia. Użyjemy przekazanego parametru command, aby określić, co należy zrobić (możesz wyobrazić sobie coś więcej niż renderowanie, np. tworzenie szablonów). Może warto je jakoś zarządzać?), a wartość context zostanie przekazana do szablonu bezpośrednio do wyrenderowania. Wyrenderowany kod HTML zostanie przekazany z powrotem na stronę rozszerzenia, aby mogło ono później wykonać z nim przydatną operację:

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

Na stronie rozszerzenia otrzymamy tę wiadomość i zrobimy coś interesującego z przekazanymi nam danymi html. W tym przypadku po prostu wyświetlimy to w powiadomieniu, ale możesz też bezpiecznie używać tego kodu HTML w interfejsie rozszerzenia. Wstawianie ich za pomocą innerHTML nie stwarza poważnego zagrożenia dla bezpieczeństwa, ponieważ ufamy treściom wyrenderowanym w piaskownicy.

Ten mechanizm upraszcza tworzenie szablonów, ale oczywiście nie ogranicza się tylko do nich. Do piaskownicy można przenieść dowolny kod, który nie działa prawidłowo w ramach rygorystycznej polityki bezpieczeństwa treści. Często warto przenieść do piaskownicy komponenty rozszerzeń, które działałyby prawidłowo, aby ograniczyć każdemu elementowi programu najmniejszy zestaw uprawnień niezbędnych do jego prawidłowego działania. Prezentacja Writing Secure Web Apps and Chrome Extensions z Google I/O 2012 zawiera dobre przykłady zastosowania tych technik i warto poświęcić na nią 56 minut.