This is part 3 of a series on migrating holas.pl from WordPress to a custom Symfony-based static site generator. Part 2 covers the architecture.


holas.pl is a static site with one exception: the contact form. Every blog post, category page, and static page is a pre-rendered HTML file served by nginx. The contact form is the only endpoint that runs PHP.

That single exception raises a specific security question: how do you protect a form on a static page from bot submissions and CSRF attacks when there's no session?

The CSRF Problem with Static Pages

Standard CSRF protection works by embedding a token in the form that is tied to the user's session. When the form is submitted, the server verifies the token matches the one in the session.

Static pages don't have sessions. The contact page is generated once during ddev build and served as a file. It can't generate a per-user token at render time. Symfony's built-in csrf_protection is therefore explicitly disabled on the contact form:

public function configureOptions(OptionsResolver $resolver): void
{
    $resolver->setDefaults([
        'csrf_protection' => false,  // deliberate — static page, no session
    ]);
}

Disabling CSRF protection is the right call here. The alternative — adding a dynamic PHP endpoint just to generate tokens for the form — would reintroduce the PHP-on-every-request problem for a page that otherwise needs none.

Cloudflare Turnstile as the Replacement

Cloudflare Turnstile is a CAPTCHA alternative that runs client-side, confirms the user is human, and provides a one-time token that can be verified server-side. It replaces CSRF as the bot-prevention layer.

The form flow:

  1. The contact page is served as static HTML with the Turnstile widget embedded (site key baked in at build time)
  2. Turnstile runs its challenge invisibly; on success it calls window.onTurnstileSuccess(token), which writes the token into a hidden field
  3. contact.js intercepts the form submit event and sends the data via fetch() instead of a standard form post
  4. The PHP endpoint receives the submission, validates the form fields, then verifies the Turnstile token with Cloudflare's API
  5. Only if both pass does the email get sent

Server-side verification is the critical step:

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('Turnstile verification failed: '.$e->getMessage());

            return false;
        }
    }
}

The validator fails closed — any exception returns false and blocks the submission. An empty or missing token also returns false immediately.

Minimal PHP Attack Surface

The entire application's PHP surface in production is two URL patterns:

location ~ ^/(api|pl/api)/ {
    fastcgi_pass $php_upstream;
    fastcgi_read_timeout 30;
}

Everything else — every blog post, every category page, the sitemap, the RSS feed, the search page — is handled by nginx serving static files. PHP-FPM is never invoked for content delivery. The attack surface for the PHP layer is a single POST endpoint.

Content Security Policy

The nginx config applies a strict CSP on every response:

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;

The only external domain permitted is challenges.cloudflare.com, which Turnstile requires for its script, iframe, and API call. No Google Analytics, no CDN scripts, no Facebook pixel. script-src has no 'unsafe-inline' and no 'unsafe-eval' — all JavaScript is loaded from hashed files via Symfony AssetMapper.

The 'unsafe-inline' in style-src is a deliberate compromise: Turnstile's widget injects inline styles that can't be avoided without a CSP nonce, and AssetMapper doesn't currently inject nonces. Everything else is locked down.

Additional Security Headers

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;

Hidden files are denied:

location ~ /\. { deny all; }

HSTS is handled upstream at Cloudflare, so there's no Strict-Transport-Security header in the nginx config — it would be redundant.

The Result

The contact form works without sessions, without CSRF tokens, and without JavaScript being required for anything other than the form submission itself. The page renders fully as static HTML. Bot submissions are blocked by Turnstile's server-side verification. The PHP endpoint is unreachable for anything other than POST requests to /api/contact.

Compared to WordPress with a contact form plugin, the attack surface went from "PHP running on every request, wp-admin exposed, XML-RPC enabled, 22 plugins any of which could have a vulnerability" to "one POST endpoint with Cloudflare verification."

The next post covers the developer experience — DDEV, the build pipeline, and deployment to production in two Docker containers.