Logo David Burdelak
Cybersecurity

Jak zabezpieczyć formularze i kod strony przed XSS?

Zabezpieczanie formularzy i kodu aplikacji www przed atakami typu Cross-Site Scripting (XSS)

XSS to podatność, którą w audytach bezpieczeństwa znajduję częściej niż jakąkolwiek inną. Wystarczy jedno pole formularza, komentarz albo parametr w adresie, którego wartość trafia na stronę bez odpowiedniego zabezpieczenia, żeby atakujący mógł uruchomić własny kod JavaScript w przeglądarce innego użytkownika: przejąć sesję, podmienić formularz płatności albo wykraść dane wpisywane na stronie.

W tym wpisie pokazuję, gdzie szukam takich luk, jak zabezpieczam formularze i kod strony oraz jak sprawdzam, że ochrona działa.

Gdzie najczęściej znajduję luki XSS

Źródło problemu jest zawsze to samo: dane pochodzące od użytkownika trafiają do kodu strony i zostają potraktowane jak kod, a nie jak tekst. To ten sam mechanizm, który przy bazie danych prowadzi do SQL Injection. W praktyce sprawdzam przede wszystkim te miejsca:

Rodzaj XSSSkąd pochodzi złośliwy kodGdzie go szukam
Trwały (stored)Zapisany w bazie i wyświetlany każdemu odwiedzającemuKomentarze, opinie o produktach, profile użytkowników, formularze kontaktowe wyświetlane w panelu administracyjnym
Odbity (reflected)Przekazany w adresie lub formularzu i od razu wyświetlony w odpowiedziWyniki wyszukiwania, komunikaty błędów walidacji, strony z parametrami w adresie
DOM-basedOdczytany przez JavaScript na stronie i wstawiony do dokumentuSkrypty odczytujące location.hash lub parametry adresu, widżety, filtry działające bez przeładowania

Szczególnie uważam na panele administracyjne. Treść formularza kontaktowego, którą widzi tylko administrator, wydaje się bezpieczna, a to właśnie konto administratora jest dla atakującego najcenniejszym celem.

Escapowanie zależne od miejsca wyświetlenia

Podstawowa ochrona to zamiana znaków specjalnych na bezpieczne odpowiedniki w momencie wyświetlania danych. Kluczowe jest to, że sposób zabezpieczenia zależy od miejsca, w którym dane trafiają do kodu. Inaczej chroni się treść akapitu, inaczej wartość atrybutu, a jeszcze inaczej dane przekazywane do skryptu.

<!-- Treść i atrybuty HTML w PHP -->
<p><?= htmlspecialchars($comment, ENT_QUOTES, 'UTF-8') ?></p>
<input value="<?= htmlspecialchars($name, ENT_QUOTES, 'UTF-8') ?>">

<!-- Dane przekazywane do JavaScriptu -->
<script>
  const config = <?= json_encode($data, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) ?>;
</script>

Przy linkach z adresami podanymi przez użytkownika dodatkowo sprawdzam protokół i dopuszczam tylko https:// i http://. Escapowanie nie ochroni przed adresem zaczynającym się od javascript:, który uruchomi kod po kliknięciu.

Systemy szablonów, takie jak Twig czy Blade, oraz frameworki React, Vue i Angular escapują dane automatycznie. Problem pojawia się tam, gdzie programista świadomie to wyłącza. Dlatego w każdym przeglądzie kodu wyszukuję miejsca, w których dane trafiają na stronę bez ochrony:

// JavaScript – niebezpiecznie i bezpiecznie
element.innerHTML = userInput;     // kod HTML zostanie zinterpretowany
element.textContent = userInput;   // dane trafią na stronę jako zwykły tekst

// Konstrukcje, które zawsze sprawdzam ręcznie
dangerouslySetInnerHTML   // React
v-html                    // Vue
{!! $zmienna !!}          // Blade
|raw                      // Twig

Dlaczego sama walidacja formularzy to za mało

Walidacja danych w formularzu jest potrzebna, ale nie chroni przed XSS sama w sobie. Sprawdzenie, że pole „imię” ma maksymalnie 50 znaków, nie zatrzyma krótkiego skryptu, a walidacja w przeglądarce w ogóle nie istnieje dla atakującego, który wysyła żądanie bezpośrednio. Dlatego w formularzach stosuję trzy warstwy:

  • Walidacja po stronie serwera – typ danych, długość, format, a tam, gdzie to możliwe, lista dozwolonych wartości.
  • Escapowanie przy każdym wyświetleniu – także wtedy, gdy formularz po błędzie walidacji pokazuje użytkownikowi wpisane wcześniej dane. To jedno z najczęstszych miejsc odbitego XSS.
  • Zabezpieczenie miejsc, w których dane wracają – podgląd w panelu administracyjnym, wiadomości e-mail w formacie HTML, eksporty do plików otwieranych w przeglądarce.

Gdy użytkownik może wstawiać HTML

W edytorach treści, opisach produktów czy komentarzach z formatowaniem nie można po prostu zamienić wszystkich znaczników na tekst. Wtedy stosuję sprawdzoną bibliotekę, która przepuszcza tylko dozwolone znaczniki i atrybuty, a usuwa skrypty, zdarzenia typu onerror i niebezpieczne adresy. Po stronie przeglądarki używam DOMPurify, a po stronie serwera w PHP biblioteki HTML Purifier. Treść czyszczę na serwerze, bo atakujący może ominąć kod działający w przeglądarce.

const clean = DOMPurify.sanitize(dirtyHTML);
element.innerHTML = clean;

Nigdy nie piszę własnych filtrów opartych na wyrażeniach regularnych, które „usuwają tag script”. Sposobów na uruchomienie kodu bez tego znacznika jest zbyt wiele, żeby taki filtr był skuteczny. Osobno traktuję też pliki SVG wgrywane przez użytkowników, bo mogą zawierać skrypty. Jeśli nie są niezbędne, po prostu ich nie dopuszczam.

Content Security Policy jako druga linia obrony

Nawet przy starannym kodzie zakładam, że gdzieś może zostać luka. Nagłówek Content Security Policy określa, skąd przeglądarka może wykonywać skrypty, i potrafi zablokować kod wstrzyknięty przez atakującego. Najskuteczniejsze jest podejście oparte na losowym identyfikatorze, tzw. nonce, generowanym przy każdym wyświetleniu strony:

Content-Security-Policy: script-src 'nonce-R4nd0mWart0sc' 'strict-dynamic'; object-src 'none'; base-uri 'none'

Przeglądarka wykona wtedy tylko skrypty oznaczone tym identyfikatorem, a skrypt wstrzyknięty w treść strony go nie zna. Wdrożenie zaczynam zawsze od trybu raportowania, czyli nagłówka Content-Security-Policy-Report-Only, żeby zobaczyć, co polityka zablokowałaby na działającej stronie, zanim zacznie cokolwiek blokować. Nagłówek ustawiam w aplikacji albo w konfiguracji serwera, np. w pliku .htaccess, choć przy nonce musi go generować aplikacja.

Ograniczenie skutków ataku

Najczęstszym celem XSS jest przejęcie sesji. Dlatego ciasteczko sesyjne zawsze ustawiam z flagami, które utrudniają jego wykorzystanie:

Set-Cookie: session_id=...; Secure; HttpOnly; SameSite=Lax

Flaga HttpOnly sprawia, że skrypt na stronie nie odczyta ciasteczka przez document.cookie, Secure wymusza przesyłanie wyłącznie przez HTTPS, a SameSite ogranicza wysyłanie ciasteczka z innych stron, co pomaga też w ochronie przed CSRF. Trzeba jednak pamiętać, że HttpOnly nie zatrzyma samego ataku. Skrypt nadal może wykonywać działania w imieniu zalogowanego użytkownika, więc to ograniczenie skutków, a nie zamiennik poprawnego escapowania.

Do tego dochodzi zasada najmniejszych uprawnień: konto redaktora nie potrzebuje prawa do instalowania wtyczek, a wrażliwe operacje, takie jak zmiana adresu e-mail czy hasła, wymagają ponownego podania hasła.

Jak sprawdzam ochronę przed XSS

  • Przegląd kodu – wyszukuję wszystkie miejsca, w których dane trafiają na stronę bez escapowania, w tym konstrukcje wymienione wyżej.
  • Testy ręczne – w każde pole formularza, parametr adresu i wyszukiwarkę wpisuję ciąg zawierający znaczniki HTML i cudzysłowy, a potem sprawdzam w kodzie źródłowym, czy trafił na stronę jako tekst, czy jako kod.
  • Skanery – narzędzia takie jak OWASP ZAP automatycznie sprawdzają formularze i parametry. Traktuję je jako uzupełnienie, bo nie znajdą wszystkiego, zwłaszcza w aplikacjach opartych na JavaScripcie.
  • Raporty CSP – zgłoszenia naruszeń polityki pokazują próby wstrzyknięcia kodu i skrypty, o których istnieniu nikt nie wiedział.

Ochrona przed XSS nie jest jednorazowym zadaniem. Każda nowa wtyczka, formularz czy widżet to potencjalne nowe miejsce, w którym dane od użytkownika trafiają na stronę, dlatego te same kontrole powtarzam przy każdym większym wdrożeniu.