XSS (Cross-Site Scripting) to atak, w którym cudzy kod JavaScript wykonuje się na Twojej stronie, bo aplikacja wstawiła dane użytkownika do HTML bez zabezpieczenia. Najprostsza obrona to wstawianie tekstu przez `textContent` zamiast `innerHTML`, a gdy musisz wstawić HTML, przepuszczasz go przez bibliotekę sanityzującą, taką jak DOMPurify. Dodatkową warstwą jest nagłówek Content Security Policy, który blokuje skrypty spoza dozwolonych źródeł.
Większość aplikacji w pewnym momencie wyświetla coś, co napisał ktoś inny: komentarz, imię, nazwę produktu, opis profilu. Jeśli wstawisz taki tekst do strony nieostrożnie, ten ktoś może wstawić tam kod, który wykona się w przeglądarce każdego odwiedzającego. To jest XSS. Poniżej sprawdzamy, jak to wygląda w praktyce, co działa, a co jest mitem, i jak się bronić. Wszystkie przykłady uruchomiłem w prawdziwej przeglądarce (Chromium), a zamiast szkodliwego kodu używam bezpiecznego znacznika window.__xss, który tylko pokazuje, że kod się wykonał.
Czym jest XSS?
Skrót oznacza Cross-Site Scripting: kod JavaScript z zewnątrz uruchamia się w kontekście Twojej strony. Przeglądarka nie odróżnia go od Twojego własnego skryptu, więc kod ma dostęp do tego, do czego ma dostęp Twoja strona: do zawartości DOM, do localStorage i do document.cookie, a także może wysyłać żądania w imieniu zalogowanego użytkownika.
| Rodzaj | Skąd bierze się złośliwy kod | Przykład |
|---|---|---|
| Stored (zapisany) | trafia do bazy i jest pokazywany każdemu | komentarz pod artykułem z kodem w treści |
| Reflected (odbity) | wraca w odpowiedzi na żądanie, np. z adresu URL | strona wyszukiwania wypisująca frazę z ?q= |
| DOM-based | powstaje wyłącznie w przeglądarce, serwer go nie widzi | skrypt wstawia location.hash przez innerHTML |
Dla początkującego frontendowca najważniejszy jest ostatni wiersz i ogólna zasada: to, co przyszło z zewnątrz, jest tekstem, nie HTML-em, dopóki świadomie nie zdecydujesz inaczej.
Jak wygląda podatny kod?
Wyobraź sobie listę komentarzy:
const out = document.getElementById('out');
function renderComment(text) {
out.innerHTML = text; // text pochodzi od użytkownika
}
renderComment('<img src=x onerror="window.__xss = 1">');
Obrazek src=x nie wczyta się, więc przeglądarka uruchomi kod z atrybutu onerror. W moim teście w Chromium licznik window.__xss zmienił się na 1, czyli cudzy kod się wykonał. W prawdziwym ataku w miejscu zmiennej __xss byłoby coś, co czyta dane lub wykonuje akcje na koncie użytkownika.
Czy skrypt wstawiony przez innerHTML się wykona?
Tu jest popularny mit. Gdy wstawisz przez innerHTML zwykły znacznik <script>, przeglądarka go nie uruchomi. W moim teście w DOM pojawił się <script>window.__xss=1</script>, ale licznik został równy 0. Nie oznacza to jednak bezpieczeństwa. Przykład powyżej pokazuje, że wystarczy inny znacznik z atrybutem zdarzenia (onerror, onload, onclick), żeby kod wystartował. Dlatego filtrowanie samego słowa <script> to nie zabezpieczenie.
Jak się bronić przed XSS?
Czy textContent rozwiązuje problem?
W większości przypadków tak. textContent wstawia wartość jako zwykły tekst. Ten sam kod z textContent zamiast innerHTML:
function renderComment(text) {
out.textContent = text;
}
W teście ten sam payload wyświetlił się na stronie jako widoczny tekst (<img ...> w DOM) i nic się nie wykonało. Jeśli budujesz elementy listy, kart czy komunikatów, użyj document.createElement razem z textContent. Różnice między sposobami zmiany treści elementu opisuje artykuł o DOM w JavaScripcie.
Co zrobić, gdy muszę wstawić HTML?
Czasem treść naprawdę zawiera formatowanie (edytor tekstu, komentarze z pogrubieniem). Wtedy przepuszczasz ją przez bibliotekę, która usuwa niebezpieczne elementy i atrybuty, a zostawia bezpieczne. Najpopularniejsza to DOMPurify:
import DOMPurify from 'dompurify';
out.innerHTML = DOMPurify.sanitize(
'<b>pogrubienie</b><img src=x onerror=alert(1)>'
);
// w DOM: <b>pogrubienie</b><img src="x">
W teście (DOMPurify 3.4.17) pogrubienie zostało, a atrybut onerror zniknął. Nie pisz własnej sanityzacji wyrażeniami regularnymi: ataków jest zbyt wiele, a gotowe biblioteki są stale poprawiane. Pamiętaj też o aktualizacji biblioteki.
Dlaczego linki od użytkownika też są ryzykowne?
Adres w href może zaczynać się od javascript:. Wtedy kliknięcie wykonuje kod:
const a = document.createElement('a');
a.href = 'javascript:window.__xss = 1';
a.textContent = 'moja strona';
out.append(a);
// po kliknięciu: window.__xss === 1
W teście po kliknięciu licznik wzrósł do 1. Jeśli pozwalasz użytkownikom podawać adresy (profil, link do strony), sprawdź protokół:
function isSafeUrl(value) {
try {
return ['http:', 'https:'].includes(new URL(value, location.href).protocol);
} catch {
return false;
}
}
isSafeUrl('javascript:alert(1)'); // false
isSafeUrl('https://devmentor.pl'); // true
Czym jest Content Security Policy i czy wystarczy sama?
Content Security Policy (CSP) to nagłówek HTTP, którym serwer mówi przeglądarce, skąd wolno ładować i uruchamiać skrypty. Przykładowa polityka:
Content-Security-Policy: script-src 'self'
Oznacza: wykonuj tylko skrypty z własnej domeny, a skryptów wbudowanych w HTML (w tym atrybutów onerror) nie. Gdy zastosowałem ją przy tym samym podatnym kodzie, <img onerror> został wstawiony do DOM, ale się nie wykonał, a konsola pokazała jedno naruszenie CSP. To mocna druga linia obrony. Ma jednak cenę: jeśli Twoja strona sama używa skryptów wbudowanych, przestaną działać, dopóki nie dodasz do nich jednorazowego klucza (nonce) albo nie przeniesiesz ich do osobnych plików. Błędy typu „Refused to execute inline script” to właśnie skutek takiej polityki. CSP traktuj jako dodatek do textContent i sanityzacji, a nie jako ich zamiennik.
Co atakujący może zabrać po udanym XSS?
To dobre pytanie, bo od odpowiedzi zależy, gdzie trzymasz wrażliwe dane. W moim teście strona ustawiła dwa ciasteczka i wpis w localStorage:
| Miejsce | Czy skrypt na stronie to odczyta |
|---|---|
localStorage (np. token jwt-abc) | tak, localStorage.getItem('token') zwrócił wartość |
Zwykłe ciasteczko (motyw=ciemny) | tak, widoczne w document.cookie |
Ciasteczko z flagą HttpOnly (sid) | nie, document.cookie go nie pokazał |
Dlatego token uwierzytelniający w localStorage jest wygodny, ale przy każdym błędzie XSS wystarczy jedna linijka, by go skopiować. Ciasteczko HttpOnly chroni przed odczytem, ale nie przed tym, by atakujący wykonał żądania w Twoim imieniu z poziomu strony, i otwiera inny problem: CSRF, czyli sfałszowane żądania wysyłane automatycznie z ciasteczkiem. Szerzej o tokenach piszę w artykule o uwierzytelnianiu w FastAPI i JWT, a o CSRF w osobnym artykule o CSRF i ochronie aplikacji.
A co z frameworkami?
Nowoczesne biblioteki domyślnie traktują wstawiane wartości jako tekst. React escapuje wyrażenia w JSX, a szablony w Pythonie (Jinja2 we Flasku) mają automatyczne escapowanie włączone dla szablonów HTML. Ochrona wyłącza się wtedy, gdy sam ją obejdziesz: w React służy do tego właściwość o znaczącej nazwie dangerouslySetInnerHTML, a w szablonach filtr |safe. Jeśli widzisz te konstrukcje w kodzie, potraktuj je jak czerwoną flagę i sprawdź, skąd pochodzi wstawiana treść. Tego zachowania nie testowałem tu osobno, opieram się na dokumentacji frameworków.
Jakie błędy mentor poprawia najczęściej?
Uwaga mentora: najczęstsze źródło XSS to nie sprytny atak, tylko wygoda. Ktoś pisze lista.innerHTML =
i działa, bo w testach imię to „Anna”. Zamień to nacreateElementitextContent`. Sanityzacja jest dla treści, które naprawdę muszą zawierać HTML.
Poniżej cała lista pułapek w jednym miejscu:
| Pułapka | Skutek | Jak uniknąć |
|---|---|---|
innerHTML z danymi z formularza lub API | wykonanie cudzego kodu | textContent lub createElement |
Filtrowanie słowa <script> | atak przez <img onerror> i inne atrybuty | sanityzacja biblioteką, a nie własnym filtrem |
href z adresem od użytkownika | kliknięcie wykonuje kod javascript: | sprawdź protokół (http:, https:) |
Token w localStorage | kradzież przy każdym błędzie XSS | rozważ ciasteczko HttpOnly i zabezpieczenie przed CSRF |
| CSP włączona i zapomniana | nie działają skrypty wbudowane | nonce albo osobne pliki, a nie unsafe-inline |
| Zaufanie do „własnego” API | dane też mogą zawierać cudzy kod | traktuj każde dane z zewnątrz jak tekst |
Wiele tutoriali, także nasz o JSON Server w JavaScripcie, używa innerHTML do wyświetlania danych z lokalnego serwera. W ćwiczeniu na własnych danych to nie szkodzi, ale nie przenoś tego wzorca do aplikacji, w której dane wpisują inni.
Co dalej?
Atak, w którym cudzy kod trafia do przeglądarki, to tylko jedna z kilku podobnych klas problemów. Podobna zasada „nie sklejaj danych użytkownika z kodem” dotyczy zapytań do bazy: zobacz SQL injection i zapytania parametryzowane. Tę samą zasadę stosuje się do poleceń systemowych i eval, co opisuje artykuł o command injection i eval. Jeśli budujesz formularz z walidacją po stronie przeglądarki, uzupełnieniem jest artykuł o walidacji formularza w JavaScripcie, a przy wgrywaniu plików przez użytkowników zajrzyj do tekstu o uploadzie plików po stronie frontendu. Jeśli chcesz nauczyć się budować bezpieczne interfejsy z mentorem, który ocenia Twój kod, sprawdź kurs JavaScript.


