Cloudflare zapamiętał HTML zamiast CSS: notatki z przenosin statycznej strony
Ten blog to statyczna strona z Nuxta na hostingu współdzielonym, za Cloudflare. Dziś przeniosłem go do nowego katalogu na tym samym koncie: zbudowana strona idzie skryptem przez FTPS, a domenę przepina się w panelu na nowy katalog. Po przepięciu strona główna wyświetlała się jako biały tekst bez stylów, a sprawdzenie z terminala mówiło, że wszystko jest w porządku. Poniżej opis, skąd ta rozbieżność i co zostało zmienione, żeby się nie powtórzyła.
Objaw: curl mówi 200, przeglądarka nie ładuje CSS
Pierwsze sprawdzenie wyglądało dobrze. HTML wskazywał na arkusz /_nuxt/entry.Czy_6vul.css, a ten plik odpowiadał poprawnie:
curl -s -o /dev/null -w '%{http_code} %{content_type}\n' https://tkulesza.eu/_nuxt/entry.Czy_6vul.css
# 200 text/css
Dopiero przeglądarka bez interfejsu (puppeteer) pokazała, co się dzieje. W konsoli pojawiał się błąd:
Failed to load module script: Expected a JavaScript-or-Wasm module script
but the server responded with a MIME type of "text/html".
Po dopisaniu do skryptu logowania odpowiedzi z katalogu _nuxt, których Content-Type zawiera html, wyszło, że ten sam adres CSS i kilka modułów JS przychodzą do przeglądarki jako HTML, ze statusem 200:
page.on('response', r => {
const ct = r.headers()['content-type'] || ''
if (r.url().includes('/_nuxt/') && ct.includes('html')) {
console.log('BAD', r.status(), ct, r.url())
}
})
Status był poprawny, typ nie. Sprawdzanie samego kodu odpowiedzi tego nie złapie.
Przyczyna: odpowiedź awaryjna z kodem 200 i cache po rozszerzeniu
Złożyły się na to dwie rzeczy.
Pierwsza to zachowanie serwera. Na tym hostingu żądanie o nieistniejący plik nie kończyło się błędem 404, tylko zwracało stronę główną z kodem 200. To ustawienie przychodzi z konfiguracji poza moim katalogiem, więc wcześniej go nie widziałem. Dla aplikacji jednostronicowej bywa to celowe, dla statycznej strony z prerenderowanymi plikami jest szkodliwe.
Druga to Cloudflare. Domyślnie zapisuje w cache odpowiedzi na podstawie rozszerzenia w adresie (.css, .js, obrazy), a nie typu treści, który zwrócił serwer. Jeśli serwer na adres entry.Czy_6vul.css odda HTML z kodem 200, Cloudflare zapisze ten HTML pod tym adresem i będzie go podawał dalej.
W czasie przenosin było okno, w którym nowy HTML z nowymi nazwami plików był już w obiegu, a żądania o te pliki trafiały jeszcze do starego katalogu. Stary katalog ich nie miał, więc odpowiadał stroną główną, a Cloudflare to zapamiętał. Nazwy plików z hashem są przy tym zdradliwe: zawartość się nie zmienia, więc nazwa też nie, i zła odpowiedź siedzi w cache tak długo, jak pozwala TTL.
Dlaczego curl dostawał poprawny plik? Przeglądarka i curl wysyłały różne nagłówki (przeglądarka między innymi prosi o kompresję) i trafiały w różne wpisy cache. Nagłówek cf-cache-status: HIT był w obu przypadkach. Nie badałem dokładnie, którym nagłówkiem różnią się te wpisy, bo wyczyszczenie cache rozwiązało problem. Praktyczny wniosek jest prostszy: test curlem nie zastępuje testu w przeglądarce.
Naprawa
Doraźnie wystarczyło wyczyścić cache w panelu Cloudflare (Caching, Configuration, Purge Everything). Żeby problem nie wrócił przy następnym wdrożeniu, strona dostała własny .htaccess, w którym brakujący plik kończy się kodem 404:
DirectorySlash Off
DirectoryIndex index.html
ErrorDocument 404 /404.html
RewriteEngine On
# /x/ → /x, gdy istnieje prerenderowana strona
RewriteCond %{DOCUMENT_ROOT}/$1/index.html -f
RewriteRule ^(.+)/$ https://tkulesza.eu/$1 [R=301,L,NE]
# /x → /x/index.html bez przekierowania
RewriteCond %{DOCUMENT_ROOT}/$1/index.html -f
RewriteRule ^(.+)$ $1/index.html [L]
# Wszystko inne, czego nie ma na dysku → 404
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^ - [R=404,L]
Najpierw próbowałem FallbackResource disabled, zakładając, że odpowiedź awaryjna pochodzi z tej dyrektywy w katalogu nadrzędnym. Nie zmieniło to niczego, więc mechanizm jest inny. Jawna reguła 404 na końcu działa niezależnie od tego, co ustawił hosting.
Dla plików z hashem doszedł nagłówek, który pozwala przeglądarkom i CDN trzymać je długo. Wyjątkiem jest _nuxt/builds/, gdzie Nuxt trzyma plik z identyfikatorem bieżącego buildu:
<If "%{REQUEST_URI} =~ m#^/_nuxt/# && %{REQUEST_URI} !~ m#^/_nuxt/builds/#">
Header always set Cache-Control "public, max-age=31536000, immutable"
</If>
<Else>
Header always set Cache-Control "no-cache"
</Else>
Kolejność wgrywania też ma znaczenie. Skrypt wdrożeniowy wgrywa teraz najpierw pliki z hashem, a dopiero potem HTML, który na nie wskazuje. Nie ma więc chwili, w której HTML odwołuje się do pliku, którego jeszcze nie ma.
Druga sprawa: wpuszczanie ruchu tylko przez Cloudflare
Przy tej samej okazji zamknąłem bezpośredni dostęp do serwera. Kto zna IP hostingu, może ominąć Cloudflare, wysyłając żądanie z nagłówkiem Host domeny prosto na ten adres. Standardowe rozwiązanie to wpuszczać tylko adresy z listy Cloudflare, na przykład przez Require ip.
Przed wgraniem takiej reguły sprawdziłem, jaki adres widzi serwer. Tymczasowy skrypt PHP wypisał REMOTE_ADDR dla żądania przez Cloudflare:
<?php
header('Content-Type: text/plain');
echo $_SERVER['REMOTE_ADDR'] ?? '-', ' | ', $_SERVER['HTTP_CF_CONNECTING_IP'] ?? '-';
Wynik pokazał dwa razy ten sam adres: mój, a nie adres Cloudflare. Hosting ma więc włączone przepisywanie adresu klienta (w Apache robi to mod_remoteip) i REMOTE_ADDR zawiera już IP odwiedzającego. Reguła Require ip z listą Cloudflare zablokowałaby wszystkich czytelników, a przepuściła tylko tych, którzy akurat mają adres z puli Cloudflare.
Apache udostępnia w wyrażeniach zmienną CONN_REMOTE_ADDR, czyli adres faktycznego połączenia TCP, którego mod_remoteip nie zmienia. Reguła sprawdza właśnie ją:
RewriteCond expr "!(%{CONN_REMOTE_ADDR} -ipmatch '173.245.48.0/20' || %{CONN_REMOTE_ADDR} -ipmatch '103.21.244.0/22' || ...)"
RewriteRule ^ - [F,L]
Lista ma 15 zakresów IPv4 i 7 IPv6. Po wgraniu sprawdziłem obie drogi:
curl -s -o /dev/null -w '%{http_code}\n' https://tkulesza.eu/
# 200
curl -sk --resolve tkulesza.eu:443:<IP hostingu> -o /dev/null -w '%{http_code}\n' https://tkulesza.eu/
# 403
Plik testowy PHP usunąłem od razu po sprawdzeniu. Lista adresów Cloudflare zmienia się rzadko, ale się zmienia. Jeśli strona przez Cloudflare zacznie kiedyś zwracać 403, pierwszym podejrzanym jest nieaktualna lista.
Uwaga o skanowaniu własnej strony
Przy sprawdzaniu, czy z zewnątrz da się pobrać pliki typu .git/config albo .env, odpytałem też kilka typowych ścieżek, między innymi wp-login.php. Ochrona przed botami na hostingu uznała to za atak i przez kilka minut zamiast strony podawała mojemu adresowi planszę „One moment, please...”. Dalszą część przeglądu zrobiłem na zbudowanych plikach lokalnie. Jeśli hosting ma taką ochronę, a sieć domowa i serwer wychodzą przez ten sam adres, warto o tym pamiętać.
Lista kontrolna przy przenosinach statycznej strony za CDN
- Brakujący plik musi zwracać 404, nie stronę główną z kodem 200. Sprawdź to na adresie, którego na pewno nie ma.
- Sprawdzaj
Content-Type, nie tylko kod odpowiedzi. Arkusz CSS z typemtext/htmlma kod 200. - Testuj w prawdziwej przeglądarce albo przez puppeteer. Curl wysyła inne nagłówki i może trafić w inny wpis cache.
- Po przepięciu katalogu lub serwera wyczyść cache CDN, zanim uznasz, że wdrożenie się udało.
- Wgrywaj pliki z hashem przed HTML, który na nie wskazuje.
- Zanim ograniczysz dostęp do adresów CDN, sprawdź, czy
REMOTE_ADDRnie jest już przepisany na adres klienta.