Przewiń do treści 

Ufam ci!

Opublikowany:
Autor:
Comandeer
Kategorie:
Adwent 2025
Standardy sieciowe
JavaScript

HTML Sanitizer API jest dobrą i skuteczną odpowiedzią na problem ataków XSS. Ma jednak jedną, zasadniczą wadę: jego dodanie do już istniejących aplikacji wymaga ingerencji w kod. I to często w wielu miejscach. Na szczęście przeglądarki udostępniają jeszcze jedno API, które sprawdzi się o wiele lepiej w takiej sytuacji.

Problem z istniejącymi aplikacjami

Żeby zabezpieczyć aplikację przy pomocy HTML Sanitizer API, trzeba zadbać o to, żeby wszystkie miejsca, w których dodajemy HTML, korzystały z nowej metody #setHTML(). Innymi słowy: wszystkie wystąpienia własności #innerHTML trzeba podmienić. I choć na pierwszy rzut oka nie wydaje się to specjalnie skomplikowane, to z powodu złożoności aplikacji może to nastręczyć sporo problemów.

Pierwszym z nich jest zmiana zachowania aplikacji. Dotąd HTML był ustawiany bez dodatkowego czyszczenia. Teraz jest filtrowany. W zależności od aplikacji, może to być problemem. Weźmy takiego CKEditora. Jeśli nagle przestanie działać dodawanie obrazków w edytorze (bo HTML Sanitizer APi je wytnie), to on sam stanie się średnio użyteczny. Stąd proste podmienienie własności #innerHTML na metodę #setHTML() w takim wypadku nie zadziała.

Drugi z problemów związany jest z samym refactorem. Bo to prawie nigdy nie jest po prostu podmiana czegoś w kodzie. Często trzeba też zmienić choćby testy, które mogą zacząć padać po zmianie zachowania aplikacji. Często też trzeba sprawdzić, czemu testy nie zaczęły padać – by się upewnić, że na pewno wszystko dobrze testują.

Trzeci problem to zależności aplikacji. Nasz kod możemy w ten czy inny sposób zrefactoryzować. Ale z zależnościami nie jest już tak łatwo i najczęściej kończy się na czekaniu, aż dany projekt doda wsparcie dla nowego API. A na to se można poczekać – o ile dany projekt w ogóle będzie chciał taką zmianę wprowadzić.

I wreszcie: kompatybilność. Dopóki HTML Sanitizer API nie będzie miało sensownego wsparcia we wszystkich najważniejszych przeglądarkach, trzeba będzie zadbać o alternatywę. Najprawdopodobniej w postaci polyfilla. A to dodatkowo niepotrzebnie skomplikuje kod i dołoży kolejną zależność do projektu.

Innymi słowy: wdrożenie HTML Sanitizer API może być sporym wyzwaniem w istniejących projektach.

Trusted Types API

Na szczęście istnieje inne API, które ma zdecydowanie lepsze wsparcie i które można wpiąć w już istniejące aplikacje przy minimalnej ingerencji. To API to Trusted Types API (API Zaufanych Typów). Pozwala ono zabezpieczyć tzw. injection sinks (miejsca wstrzykiwania) przy pomocy polityk.

Miejsca wstrzykiwania

Miejsca wstrzykiwania to wszystkie te fragmenty kodu, w których do strony WWW jest dodawana treść lub może być wykonany skrypt JS. Najprostszym przykładem jest własność #innerHTML, przy pomocy której można wsadzić HTML do wybranego elementu. Są też jednak mniej oczywiste przykłady:

Takie przykłady można by mnożyć. MDN zawiera sporą listę potencjalnych miejsc wstrzykiwania.

Polityki

Trzonem Trusted Types API są tzw. polityki. To obiekty zawierające konfigurację opisują, w jaki sposób mają być zabezpieczone poszczególne miejsca wstrzykiwania. Każda polityka może składać się z trzech metod-fabryk:

Żeby dodać nową politykę, trzeba skorzystać z metody trustedTypes#createPolicy():

const policy = trustedTypes.createPolicy( 'my-policy', { // 1
	createHTML( html ) { // 2
		return sanitizeHTML( html );
	},

	createScript( js ) { // 3
		return sanitizeJS( js );
	},

	createScriptURL( url ) { // 4
		return sanitizeURL( url );
	}
} );

Tworzymy nową politykę o nazwie my-policy i zapisujemy ją do zmiennej policy (1). Ma ona wszystkie trzy metody – #createHTML() (2), #createScript() (3) oraz #createScriptURL() (4). Zwracają one przekazany im kod przepuszczony przez odpowiednie funkcje filtrujące (5, 6, 7 ). To, co te funkcje robią, zależy w zupełności od nas. W końcu każda aplikacja jest inna i może mieć inne wymagania odnośnie czyszczenia HTML-a czy JS-a. Na potrzeby przykładu stwórzmy zaślepki, które po prostu zwrócą przekazany im argument:

function sanitizeHTML( html ) {
	return html;
}

function sanitizeJS( js ) {
	return js;
}

function sanitizeURL( url ) {
	return url;
}

Żeby wykorzystać w praktyce naszą politykę, trzeba wywołać jej metody:

const div = document.createElement( 'div' ); // 1

div.innerHTML = policy.createHTML( '<p>Jestem zaufanym HTML-em</p>' ); // 2

eval( policy.createScript( 'alert(1)' ) ); // 3

const script = document.createElement( 'script' ); // 4

script.src = policy.createScriptURL( 'data:text/javascript;charset=utf-8;base64,YWxlcnQoMSk=' ); // 5

Tworzymy element div (1), a następnie wsadzamy do niego “zaufany” HTML, stworzony przy pomocy metody policy#createHTML() (2). Z kolei do funkcji eval() przekazujemy “zaufany” skrypt, stworzony przy pomocy metody policy#createScript() (3). Na koniec tworzymy element script (4) i ustawiamy jego własność #src na “zaufany” URL skryptu stworzony przy pomocy metody policy#createScriptURL() (5).

Wymuszanie polityki

Samo jednak dodanie polityki nie zabezpieczy aplikacji. Przeglądarka nie będzie miała żadnego problemu z tym, żeby ją ominąć i ustawić własność #innerHTML na dowolny tekst. Dlatego API dostarcza też mechanizm, który wymusza stosowanie polityki. Służy do tego dyrektywa require-trusted-types-for w nagłówku HTTP Content-Security-Policy:

Content-Security-Policy: require-trusted-types-for 'script';

Jak na razie jedyną wartością dla tej dyrektywy jest 'script', która pokrywa wszystkie trzy przypadki (kod HTML, kod JS i URL-e skryptów).

Jeśli wyślemy taki nagłówek HTTP, a następnie spróbujemy wstawić niezabezpieczony tekst do własności #innerHTML, powinniśmy otrzymać następujący błąd:

This document requires 'TrustedHTML' assignment. The action has been blocked.

Istnieje też dyrektywa trusted-types, która kontroluje tworzenie polityk:

Content-Security-Policy: require-trusted-types-for 'script';trusted-types my-policy;

Taki nagłówek wymusi używanie polityki, a równocześnie pozwoli stworzyć jedynie taką o nazwie my-policy.

Domyślna polityka

Ale zaraz, zaraz – czy to API nie miało być tym “mniej inwazyjnym”, które nie wymaga sporego refactoru istniejących aplikacji? Takie tworzenie polityk wydaje się wręcz jeszcze bardziej upierdliwe, niż prosta zamiana #innerHTML na #setHTML(). Na całe szczęście Trusted Types API ma asa w rękawie: domyślną politykę. Jeśli wymusimy stosowanie polityk przy pomocy dyrektywy require-trusted-types-for, a następnie stworzymy politykę o nazwie default, będzie ona automatycznie wykorzystywana w każdym miejscu wstrzykiwania:

trustedTypes.createPolicy( 'default', { // 1
	createHTML( html ) {
		console.log( 'HTML' ); // 5

		return html; // 2
	},

	createScript( js ) {
		console.log( 'JS' ); // 6

		return js; // 3
	},

	createScriptURL( url ) {
		console.log( 'URL' ); // 7

		return url; // 4
	}
} );

Tworzymy nową politykę o nazwie 'default' (1). Ponownie tworzymy metody-fabryki, w których zwracamy przekazany im kod (2, 3, 4). Wcześniej jednak logujemy w konsoli, jaki typ danych filtrujemy (5, 6, 7)

Wykorzystanie tej polityki jest całkowicie przezroczyste:

const div = document.createElement( 'div' );

div.innerHTML = '<p>Jestem zaufanym HTML-em</p>'; // 1

eval( 'alert(1)' ); // 2

const script = document.createElement( 'script' );

script.src = 'data:text/javascript;charset=utf-8;base64,YWxlcnQoMSk='; // 3

Nie trzeba się już odwoływać do polityki ani przy przypisywaniu wartości do własności #innerHTML (1), ani przy wywoływaniu funkcji eval() (2), ani przy ustawianiu źródła skryptu (3).

Jeśli odpalimy ten kod w przeglądarce, wówczas powinniśmy zobaczyć w konsoli logi potwierdzające, że polityka została zaaplikowana do każdego z trzech miejsc wstrzykiwania.

Kompatybilność

Specyfikacja Trusted Types API jest już na dość zaawansowanym etapie standaryzacji. Choć wciąż może się zmieniać, nie oczekiwałbym jakichś wielkich, wywrotowych zmian. Jak już jakieś nastąpią, to raczej kosmetyczne. Natomiast wsparcie jest już od dłuższego czasu zarówno w Chrome, jak i w Safari. W Firefoksie wciąż jest eksperymentalne za flagą.

Myślę, że dopóki wsparcie nie będzie we wszystkich najważniejszych przeglądarkach, najlepiej się ograniczyć do tworzenia domyślnej polityki i wymuszać ją przy pomocy dyrektywy require-trusted-types-for. Dzięki temu całość będzie jak najbardziej transparentna dla przeglądarek, które wciąż nie wspierają tego API. Natomiast cała reszta dostanie lepsze zabezpieczenie przed atakami XSS.

I w końcu będzie można zacząć ufać własności #innerHTML!

Komentarze

Przejdź do komentarzy bezpośrednio na Githubie.