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.
Read in EnglishPortfolio 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ł ifont-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órestyle-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.txtzgodnie 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 wpackage-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), uruchamianpm 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>, atrybutstyle=albodata: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
- Mozilla HTTP Observatory: A+, 155 punktów, 12/12 testów
- SSL Labs: A+ / A. Ocena zależy od tego, na który z serwerów Vercela trafi test.
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.