To część 3 serii o przygotowaniu notACMS do wydania jako open-source. Część 2 opisuje audyt kodu przez AI. Oryginalna seria o migracji z WordPressa opisuje samą migrację.


"Statyczne strony są bezpieczne." To najczęstsza rzecz jaką ludzie mówią, gdy powiesz im że zamieniłeś WordPressa na statyczny HTML. I to prawda — o ile "statyczna" oznacza "bez bazy danych i bez kont użytkowników". W momencie gdy statyczna strona ma formularz kontaktowy, wyszukiwarkę albo jakikolwiek JavaScript renderujący treść od użytkownika, powierzchnia ataku nie jest zerowa. Jest po prostu inna.

Oto luki które znalazłem w mojej.

XSS w search.js — i dlaczego oczywista poprawka była błędna

Pagefind zwraca wyniki wyszukiwania jako JSON. JavaScript renderuje je przez innerHTML. Po code review naturalnym odruchem było escapowanie fragmentu:

// "Naprawione" w 1.1.2
'<p class="post-card-excerpt">' + esc(r.excerpt) + '</p>'

Przeszło review. Wylądowało w kodzie. Potem ktoś coś wyszukał i zauważył że podświetlone trafienie nie jest już pogrubione — wyświetlało się jako literalny tekst:

…wyniki dla <mark>notacms</mark> w…

Pagefind wstrzykuje tagi <mark> do fragmentów w czasie zapytania, żeby podświetlić dopasowane wyrazy. esc() escapował te tagi razem ze wszystkim innym. "Poprawka bezpieczeństwa" po cichu zepsuła podświetlanie wyników.

Revert:

// Aktualny kod
'<p class="post-card-excerpt">' + r.excerpt + '</p>'

To nie jest zostawienie luki. r.excerpt to nie input użytkownika — jest generowany przez pagefind z własnego, pre-indeksowanego statycznego HTML. Jedyny HTML jaki zawiera to tagi <mark> wstrzyknięte przez sam pagefind. Właściwa zasada to nie "zawsze escapuj przed innerHTML" — to wiedz kto kontroluje stringa. Wszystkie inne pola (r.url, r.meta.title, r.meta.category, tagi) są escapowane, bo pochodzą z frontmattera który w teorii może zawierać cokolwiek.

Naiwne escapowanie było gorsze niż brak poprawki: wprowadziło regresję i dało fałszywe poczucie że problem XSS został rozwiązany.

Open redirect przez nagłówek Referer

Kontrolery podglądu draftów i zaplanowanych postów przełączają widoczność i przekierowują z powrotem:

// Luka (przed 1.1.x)
return $this->redirect($request->headers->get('referer', '/'));

Nagłówek Referer jest kontrolowany przez użytkownika. Atakujący może stworzyć link który przeprowadzi użytkownika przez przełączenie podglądu a potem przekieruje go na dowolną zewnętrzną stronę. Poprawka z 1.1.x walidowała czy cel przekierowania to lokalna ścieżka:

// Naprawione w 1.1.x
$referer = $request->headers->get('referer', '/');
if ($referer && str_starts_with($referer, '/') && !str_starts_with($referer, '//')) {
    return $this->redirect($referer);
}
return $this->redirect('/');

W 1.2.0 to zostało wzmocnione. Sprawdzenie str_starts_with operuje na surowym stringu nagłówka — poprawne, ale kruche przy niestandardowych lub zniekształconych wartościach. Ulepszona wersja używa parse_url() żeby osobno wyodrębnić host i ścieżkę, zwalidować każdy komponent z osobna i przekierować tylko do ścieżki (nigdy do pełnej wartości nagłówka):

// Ulepszone w 1.2.0
$referer = (string) $request->headers->get('referer', '');
$refererParts = parse_url($referer);
$refererHost = is_array($refererParts) ? ($refererParts['host'] ?? null) : null;
$refererPath = is_array($refererParts) ? ($refererParts['path'] ?? '') : '';

if (
    (null === $refererHost || $request->getHost() === $refererHost)
    && str_starts_with($refererPath, '/')
    && !str_starts_with($refererPath, '//')
) {
    return $this->redirect($refererPath);
}
return $this->redirect('/');

Path traversal w MediaController

Cache wariantów serwuje przeskalowane obrazki. Nazwa pliku wariantu pochodzi z URL:

$variantPath = $this->cacheDir . '/' . $variantFilename;

Bez guarda realpath(), spreparowany URL typu ../../etc/passwd mógłby uciec z katalogu cache. Rozwiązanie to pojedyncze sprawdzenie:

$realCacheDir = realpath($this->cacheDir);
if (false === $realCacheDir || !str_starts_with(dirname($variantPath), $realCacheDir)) {
    throw new NotFoundHttpException('Invalid variant path');
}

CSRF na pre-renderowanych formularzach

To kwestia architektoniczna, nie błąd. Tokeny CSRF Symfony są powiązane z sesją użytkownika. Ale statyczne strony nie mają sesji — HTML jest generowany raz w czasie buildu. Każdy odwiedzający dostałby ten sam token, wypalony w statycznym pliku, który nie może się zwalidować wobec jego własnej sesji.

Rzeczywiste zabezpieczenia są inne:

  1. Turnstile CAPTCHA — zastępuje warstwę ochrony przed botami weryfikacją po stronie serwera
  2. X-Requested-With: XMLHttpRequest — blokuje proste submit formularzy z klientów nie-JS
  3. Ograniczenie nginx — PHP-FPM dostępne tylko na ^/(api|pl/api)/

Flag Secure cookie — flaga która po cichu psuje dev

Przekierowanie locale ustawia cookie lang po stronie klienta. ; Secure było w kodzie od pierwszego commita:

document.cookie = COOKIE + '=' + encodeURIComponent(value) + '; path=/; SameSite=Lax; Secure';

Potem podczas developmentu na DDEV przez HTTP przekierowanie locale przestało działać. Kliknięcie przełącznika języka nie robiło nic. Cookie po prostu nie było.

; Secure mówi przeglądarce żeby ustawiała cookie tylko na originie HTTPS. Na HTTP po cichu nie robi nic — bez błędu, bez ostrzeżenia, bez komunikatu w konsoli. Mechanizm locale był kompletnie niefunkcjonalny na każdym originie HTTP.

Poprawka to uzależnienie od protokołu:

var secure = window.location.protocol === 'https:' ? '; Secure' : '';
document.cookie = COOKIE + '=' + encodeURIComponent(value) + '; path=/; SameSite=Lax' + secure;

Produkcja jest zawsze HTTPS, więc ; Secure jest tam zawsze dodawane. Dev przez HTTP dostaje działające cookie bez niego. Baner cookie używa bezwarunkowego ; Secure — zgoda na ciasteczka nie musi działać na HTTP dev, więc tam to nie problem.

Lekcja to nie "pamiętaj dodać ; Secure". To że flagi bezpieczeństwa które po cichu nie działają to osobna klasa problemów. Dodajesz je, czujesz się bezpiecznie, i nie wiesz że coś zepsułeś dopóki nie ugrzęźniesz w debugowaniu czegoś zupełnie innego.

Przed czym "statyczna" strona naprawdę chroni

SQL injection — brak bazy danych. Server-side template injection — szablony kompilowane w czasie buildu. Ataki upload plików — brak uploadów. Ataki brute-force na logowanie — brak strony logowania.

Przed czym nie chroni: XSS po stronie klienta, open redirecty, path traversal w dynamicznych endpointach, CSRF na formularzach, brakujące nagłówki bezpieczeństwa, JavaScript który ufa niezaufanym danym.

Formularz kontaktowy: jedyny dynamiczny endpoint jest najbardziej atakowany

nginx ogranicza PHP-FPM do jednego wzorca:

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

Reszta serwowana z dysku. Formularz kontaktowy ma weryfikację Turnstile, ciasny CSP który dopuszcza tylko challenges.cloudflare.com jako zewnętrzną domenę, bez 'unsafe-inline' i 'unsafe-eval' w script-src:

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;

'unsafe-inline' w style-src to świadomy kompromis: widget Turnstile wstrzykuje inline styles których nie da się uniknąć bez CSP nonce. Reszta jest zamknięta.

W następnym poście opisuję wzorzec lokalnych nadpisan — jak notACMS pozwala użytkownikom customizować szablony, CSS i tłumaczenia bez forka.