EN
← wszystkie wpisy

Jak zabezpieczyłem tę stronę

Statyczna strona, CSP bez inline, DNSSEC, DMARC i CI, które pilnuje, żebym sam niczego nie zepsuł. Co zrobiłem, na czym się wyłożyłem i dlaczego prawie nie ma tu JavaScriptu.

  • CSP
  • nagłówki HTTP
  • DNS
  • supply chain
Read in English

Portfolio kogoś, kto chce pracować w bezpieczeństwie, samo jest kawałkiem CV. Rekruter z security może otworzyć DevTools, sprawdzić nagłówki albo wrzucić adres do skanera. Uznałem, że ta strona ma być przykładem tego, co mówię, a nie tylko o tym opowiadać.

Najpierw: jak najmniej rzeczy, które da się zaatakować

Najprostsza obrona to brak powierzchni ataku. Ta strona jest w pełni statyczna: nie ma serwera aplikacji, bazy danych, logowania, formularzy ani API. Są tylko pliki HTML, CSS i fonty, budowane raz i serwowane z CDN.

Nie ma też ciasteczek, trackerów, analityki, zewnętrznych skryptów ani CDN-ów z fontami. Fonty leżą na tej samej domenie, więc przeglądarka odwiedzającego nie łączy się z nikim poza mną. Skoro nie ma cookies, nie ma też bannera cookies.

Projekt zacząłem w Next.js, ale szybko zmieniłem go na Astro. Statyczny eksport Next.js wstawia do każdej strony skrypty inline, więc CSP wymagałoby 'unsafe-inline' albo żonglowania hashami przy każdym buildzie. Astro domyślnie nie wysyła do przeglądarki żadnego JavaScriptu.

Content Security Policy

CSP mówi przeglądarce, skąd strona może ładować zasoby. Wszystko spoza listy jest blokowane, nawet jeśli ktoś zdołałby wstrzyknąć kod do HTML. Moja polityka:

Content-Security-Policy:
  default-src 'none';
  script-src 'self';
  style-src 'self';
  img-src 'self';
  font-src 'self';
  connect-src 'none';
  object-src 'none';
  base-uri 'none';
  form-action 'none';
  frame-ancestors 'none';
  upgrade-insecure-requests

Zaczynam od default-src 'none', czyli „nic nie wolno”, i dopuszczam tylko to, czego strona naprawdę potrzebuje, wyłącznie z własnej domeny. Nie ma 'unsafe-inline' ani 'unsafe-eval', więc nawet wstrzyknięty <script> albo atrybut onclick się nie wykona. form-action 'none' i base-uri 'none' zamykają dwie mniej oczywiste furtki: przekierowanie formularza i podmianę bazowego adresu linków. frame-ancestors 'none' blokuje osadzanie strony w ramce, czyli clickjacking.

Na początku miałem script-src 'none', bo strona nie miała ani linijki JS. Potem doszło menu mobilne na natywnym Popover API, które działa bez skryptów, ale nie zamyka się po kliknięciu linku. Dodałem do tego jeden plik menu.js (7 linijek) i zmieniłem politykę na 'self'. Bez JS menu nadal działa, tylko trzeba je zamknąć ręcznie.

Na czym się wyłożyłem

Ścisłe CSP szybko wyłapuje rzeczy, które narzędzia robią „za plecami”:

  • Font jako data: URI. Vite domyślnie wkleja pliki poniżej 4 KB prosto do CSS jako base64. Jeden mały plik fontu się na to załapał i font-src 'self' go zablokowało. Mogłem dopisać data: do polityki, ale wolałem ustawić assetsInlineLimit: 0, żeby każdy plik był osobny.
  • Podświetlanie kodu. Domyślny highlighter w Astro (Shiki) koloruje kod atrybutami style="...", które style-src 'self' blokuje. Przełączyłem go na Prism, który używa klas CSS.
  • „CSP blocks eval” w konsoli. To nie był mój kod, tylko rozszerzenia przeglądarki, które wstrzykują skrypty do każdej strony. CSP zrobiło dokładnie to, co miało zrobić.

Pozostałe nagłówki

Nagłówek Wartość Po co
Strict-Transport-Security max-age=63072000; includeSubDomains; preload Tylko HTTPS przez 2 lata. Domena .dev i tak jest na liście HSTS preload.
X-Content-Type-Options nosniff Przeglądarka nie zgaduje typu pliku.
X-Frame-Options DENY Blokada ramek dla starszych przeglądarek (nowsze używają frame-ancestors).
Referrer-Policy strict-origin-when-cross-origin Inne strony nie dostają pełnego adresu, z którego przyszedł użytkownik.
Permissions-Policy camera=(), microphone=(), geolocation=(), … Strona z góry rezygnuje z dostępu do kamery, mikrofonu, lokalizacji i płatności.
Cross-Origin-Opener-Policy same-origin Izolacja okna od stron otwartych z innych domen.
Cross-Origin-Embedder-Policy require-corp Strona ładuje tylko zasoby, które jawnie na to pozwalają.
Cross-Origin-Resource-Policy same-origin Moich plików nie da się osadzić na obcych stronach.

DNS i poczta

Bezpieczeństwo nie kończy się na HTTP. Ktoś, kto przejmie DNS albo podszyje się pod domenę w mailu, ominie wszystkie nagłówki.

  • DNSSEC jest włączony: odpowiedzi DNS dla domeny są podpisane, a rekord DS siedzi w rejestrze .dev. Podrobionego rekordu nie da się podsunąć resolverowi, który waliduje podpisy.
  • SPF, DKIM i DMARC z polityką p=quarantine: serwery pocztowe wiedzą, które serwery mogą wysyłać maile z mojej domeny, a podróbki lądują w spamie.
  • Blokada transferu domeny u rejestratora, więc nikt nie przeniesie jej po cichu do innego.
  • /.well-known/security.txt zgodnie z RFC 9116: jeśli ktoś znajdzie problem, wie, gdzie go zgłosić.

Łańcuch dostaw

Większość kodu, który trafia do buildu, nie jest mój, więc pilnuję zależności:

  • wersje paczek są przypięte (--save-exact) i zapisane w package-lock.json, a do buildu biorę wersje mające przynajmniej kilka dni, nie te sprzed godziny;
  • CI przy każdym pushu robi czystą instalację (npm ci --ignore-scripts), uruchamia npm audit (zatrzymuje się na podatnościach high/critical) i buduje stronę;
  • ten sam workflow sprawdza zbudowany HTML i CSS: jeśli pojawi się inline <script>, <style>, atrybut style= albo data: w CSS, build się wywala. To właśnie ten błąd z fontem, tylko złapany automatycznie;
  • GitHub Actions są przypięte do konkretnych commitów (SHA), a nie tagów, więc nikt nie podmieni kodu akcji pod tym samym numerem wersji. Workflow ma tylko uprawnienie do odczytu repo;
  • Dependabot co tydzień proponuje aktualizacje paczek i akcji.

Prywatność

Na stronie jest tylko to, co chcę pokazać: imię, nazwisko, miasto i adres e-mail w mojej domenie. Nie ma numeru telefonu, daty urodzenia ani adresu. Przed publikacją zdjęcia i CV usuwam z nich metadane, czyli EXIF z GPS-em i modelem telefonu oraz autora i ścieżki w PDF.

Wyniki

Czego się nauczyłem

Najwięcej dało mi nie samo ustawienie nagłówków, tylko zrozumienie, co je łamie. Każdy wyjątek w CSP, który kusi, żeby go dopisać, to znak, że jakieś narzędzie robi coś, czego nie widać. Lepiej zmienić konfigurację narzędzia niż poluzować politykę. A test, który sam pilnuje, żeby problem nie wrócił, jest wart więcej niż jednorazowa poprawka.

← wszystkie wpisy