XSS, open redirecty i path traversal na 'statycznej' stronie
bezpieczenstwo,statyczna-strona,php,nginxCzęść 3 z 7
- 368 testów dla statycznego generatora stron — po co?
- 79 błędów w kodzie Symfony który przeszedł PHPStan
- XSS, open redirecty i path traversal na 'statycznej' stronie
- Wzorzec lokalnych nadpisań — szablony Symfony bez forkowania
- Open-source notACMS — pełna lista kontrolna
- Jak notACMS buduje statyczną stronę — pełny pipeline
- Budowanie produkcyjnego motywu notACMS — od szablonu do wydania
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:
- Turnstile CAPTCHA — zastępuje warstwę ochrony przed botami weryfikacją po stronie serwera
X-Requested-With: XMLHttpRequest— blokuje proste submit formularzy z klientów nie-JS- 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.