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:

  1. Strona kontaktowa jest serwowana jako statyczny HTML z osadzonym widżetem Turnstile (klucz strony jest wbudowany podczas buildu)
  2. Turnstile uruchamia swoje wyzwanie niewidocznie; po sukcesie wywołuje window.onTurnstileSuccess(token), który zapisuje token do ukrytego pola
  3. contact.js przechwytuje zdarzenie submit formularza i wysyła dane przez fetch() zamiast standardowego wysłania formularza
  4. Endpoint PHP otrzymuje przesłanie, weryfikuje pola formularza, następnie weryfikuje token Turnstile przez API Cloudflare
  5. 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.