Zabezpieczenie jedynego dynamicznego endpointu strony statycznej — formularz kontaktowy
symfony,php,bezpieczenstwo,cloudflare,nginxCzęść 3 z 5
- 19 lat z WordPressem — dlaczego w końcu zrezygnowałem
- Symfony jako generator stron statycznych — jak działa holas.pl
- Zabezpieczenie jedynego dynamicznego endpointu strony statycznej — formularz kontaktowy
- Drzewo decyzyjne narzędzi dla Claude Code z globalną pamięcią
- Środowisko deweloperskie — od lokalnego devu do produkcji w dwóch kontenerach
To część 3 serii o migracji holas.pl z WordPressa do własnego generatora stron statycznych opartego na Symfony. Część 2 opisuje architekturę.
holas.pl jest stroną statyczną z jednym wyjątkiem: formularzem kontaktowym. Każdy wpis blogowy, strona kategorii i strona statyczna to wstępnie wyrenderowany plik HTML serwowany przez nginx. Formularz kontaktowy to jedyny endpoint uruchamiający PHP.
To jedno wyjście rodzi konkretne pytanie bezpieczeństwa: jak chronić formularz na stronie statycznej przed botami i atakami CSRF, gdy nie ma sesji?
Problem CSRF na stronach statycznych
Standardowa ochrona CSRF działa przez osadzenie tokena w formularzu powiązanego z sesją użytkownika. Przy przesyłaniu formularza serwer weryfikuje, czy token pasuje do tego z sesji.
Strony statyczne nie mają sesji. Strona kontaktowa jest generowana raz podczas ddev build i serwowana jako plik. Nie może generować tokena per użytkownik w czasie renderowania. Wbudowana csrf_protection Symfony jest więc jawnie wyłączona w formularzu kontaktowym:
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'csrf_protection' => false, // celowe — strona statyczna, brak sesji
]);
}
Wyłączenie ochrony CSRF to właściwa decyzja. Alternatywa — dodanie dynamicznego endpointu PHP tylko do generowania tokenów dla formularza — przywróciłaby problem PHP-przy-każdym-żądaniu dla strony, która poza tym go nie potrzebuje.
Cloudflare Turnstile jako zamiennik
Cloudflare Turnstile to alternatywa dla CAPTCHA działająca po stronie klienta, potwierdzająca człowieczeństwo użytkownika i dostarczająca jednorazowy token weryfikowalny po stronie serwera. Zastępuje CSRF jako warstwa zapobiegania botom.
Przepływ formularza:
- Strona kontaktowa jest serwowana jako statyczny HTML z osadzonym widżetem Turnstile (klucz strony jest wbudowany podczas buildu)
- Turnstile uruchamia swoje wyzwanie niewidocznie; po sukcesie wywołuje
window.onTurnstileSuccess(token), który zapisuje token do ukrytego pola contact.jsprzechwytuje zdarzeniesubmitformularza i wysyła dane przezfetch()zamiast standardowego wysłania formularza- Endpoint PHP otrzymuje przesłanie, weryfikuje pola formularza, następnie weryfikuje token Turnstile przez API Cloudflare
- Tylko jeśli oba sprawdzenia przejdą, e-mail jest wysyłany
Weryfikacja po stronie serwera to kluczowy krok:
final class TurnstileValidator
{
public function verify(string $token, string $remoteIp): bool
{
try {
$response = $this->httpClient->request('POST', 'https://challenges.cloudflare.com/turnstile/v0/siteverify', [
'body' => [
'secret' => $this->secretKey,
'response' => $token,
'remoteip' => $remoteIp,
],
]);
$data = $response->toArray();
return true === ($data['success'] ?? false);
} catch (\Throwable $e) {
$this->logger->error('Weryfikacja Turnstile nie powiodła się: '.$e->getMessage());
return false;
}
}
}
Walidator zawodzi bezpiecznie — każdy wyjątek zwraca false i blokuje przesłanie. Pusty lub brakujący token również zwraca false natychmiast.
Minimalna powierzchnia ataku PHP
Cała powierzchnia PHP aplikacji w produkcji to dwa wzorce URL:
location ~ ^/(api|pl/api)/ {
fastcgi_pass $php_upstream;
fastcgi_read_timeout 30;
}
Wszystko inne — każdy wpis blogowy, każda strona kategorii, mapa strony, kanał RSS, strona wyszukiwania — jest obsługiwane przez nginx serwujący pliki statyczne. PHP-FPM nie jest nigdy wywoływane przy dostarczaniu treści. Powierzchnia ataku warstwy PHP to jeden endpoint POST.
Content Security Policy
Konfiguracja nginx stosuje rygorystyczny CSP przy każdej odpowiedzi:
add_header Content-Security-Policy
"default-src 'self';
script-src 'self' challenges.cloudflare.com;
style-src 'self' 'unsafe-inline';
img-src 'self' data:;
frame-src challenges.cloudflare.com;
connect-src 'self' challenges.cloudflare.com;"
always;
Jedyną dozwoloną zewnętrzną domeną jest challenges.cloudflare.com, której Turnstile wymaga dla swojego skryptu, iframe i wywołania API. Brak Google Analytics, skryptów CDN, piksela Facebooka. script-src nie ma 'unsafe-inline' ani 'unsafe-eval' — cały JavaScript jest ładowany z plików z hashami przez Symfony AssetMapper.
'unsafe-inline' w style-src to celowy kompromis: widżet Turnstile wstrzykuje style inline, których nie można uniknąć bez nonce CSP, a AssetMapper aktualnie nie wstrzykuje nonces. Wszystko inne jest zablokowane.
Dodatkowe nagłówki bezpieczeństwa
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
Ukryte pliki są blokowane:
location ~ /\. { deny all; }
HSTS jest obsługiwane przez Cloudflare, więc nie ma nagłówka Strict-Transport-Security w konfiguracji nginx — byłby zbędny.
Wynik
Formularz kontaktowy działa bez sesji, bez tokenów CSRF i bez JavaScriptu wymaganego do czegokolwiek poza samym wysłaniem formularza. Strona renderuje się w pełni jako statyczny HTML. Przesłania botów są blokowane przez weryfikację Turnstile po stronie serwera. Endpoint PHP jest nieosiągalny dla czegokolwiek poza żądaniami POST do /api/contact.
W porównaniu z WordPressem z wtyczką formularza kontaktowego, powierzchnia ataku zmalała z "PHP działającego przy każdym żądaniu, wp-admin wystawione, XML-RPC włączone, 22 wtyczki, każda mogąca mieć lukę" do "jeden endpoint POST z weryfikacją Cloudflare".
Następny wpis opisuje środowisko deweloperskie — DDEV, pipeline buildu i deployment do produkcji w dwóch kontenerach Docker.