Astro & Cloudflare 19 września 2026 12 min czytania

Plik _headers nie działa na Cloudflare Workers. Jak ustawić nagłówki bezpieczeństwa w Astro

ŁD

Autor: Łukasz Dramiński (Web Developer Freelancer)

Opisany przypadek pochodzi z wdrożenia tej strony na Cloudflare Workers.

Dodajesz do projektu plik public/_headers, wdrażasz, otwierasz narzędzia deweloperskie — i nagłówków nie ma. Plik jest w katalogu wynikowym, format się zgadza, dokumentacja Cloudflare opisuje go dokładnie tak, jak go napisałeś. A odpowiedź wraca goła. Ten wpis wyjaśnia, dlaczego tak się dzieje na Cloudflare Workers, co zrobić zamiast tego i dlaczego samo middleware w Astro też nie zamyka tematu.

KOGO DOTYCZY Projekt w Astro z ustawieniem output: 'server' i adapterem @astrojs/cloudflare, wdrażany jako Worker ze statycznymi zasobami. Ta sama mechanika obowiązuje w SvelteKit, Nuxt i Next na tym samym adapterze — nazwa pliku z middleware będzie inna, reszta identyczna.

Dlaczego _headers omija Twoje podstrony

Powód jest jednozdaniowy i stoi w dokumentacji Cloudflare, tyle że w miejscu, do którego zagląda się dopiero po godzinie szukania. Nagłówki zdefiniowane w pliku _headers nie są stosowane do odpowiedzi wygenerowanych przez kod Twojego Workera — nawet wtedy, gdy adres żądania pasuje do reguły zapisanej w pliku.

Teraz wystarczy zestawić to z tym, czym jest projekt Astro w trybie serwerowym. Przy output: 'server' żadna podstrona nie leży na dysku jako gotowy plik HTML. Każda powstaje w momencie żądania, w kodzie Workera. Czyli dokładnie ta część serwisu, którą chcesz zabezpieczyć, jest tą, której plik _headers z definicji nie obsługuje.

Najbardziej myli to, że plik nie jest ignorowany w całości. Obrazy, favicon, robots.txt i pliki do pobrania nagłówki dostaną, bo one są zasobami statycznymi. Widzisz więc stan, w którym ta sama konfiguracja raz działa, a raz nie — i naturalnie szukasz błędu w składni pliku, zamiast w tym, czego plik w ogóle dotyczy.

Skąd bierze się to nieporozumienie: Pages kontra Workers

Plik _headers przyszedł z Cloudflare Pages, gdzie hostowało się strony zbudowane statycznie. Tam każda podstrona była plikiem w katalogu wynikowym, więc jedna reguła /* obejmowała praktycznie wszystko, co widział użytkownik. Poradniki, wpisy na forach i odpowiedzi modeli językowych opisują właśnie ten układ, bo powstały w tamtych czasach i nikt ich potem nie poprawił.

Po przeniesieniu na Workers format pliku został ten sam, a znaczenie słowa „zasób statyczny" zmieniło się pod spodem. Rada nie zwraca błędu, nie ostrzega, nie wypisuje nic w logu. Po prostu przestaje obejmować to, na czym Ci zależy. To jest najgorszy rodzaj nieaktualnej dokumentacji — taki, który wygląda na działający.

Rozwiązanie dla podstron: middleware w Astro

Skoro podstrony generuje kod Workera, to nagłówki trzeba dopisać w tym kodzie. W Astro służy do tego middleware — plik src/middleware.ts, który wykonuje się dla każdego żądania obsługiwanego przez aplikację. Poniżej wersja, która stoi na tej stronie:

// src/middleware.ts
import { defineMiddleware } from 'astro:middleware';

export const onRequest = defineMiddleware(async (context, next) => {
    const response = await next();

    response.headers.set('Strict-Transport-Security', 'max-age=31536000; includeSubDomains; preload');
    response.headers.set('Cross-Origin-Opener-Policy', 'same-origin');
    response.headers.set('X-Content-Type-Options', 'nosniff');
    response.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
    response.headers.set('Permissions-Policy', 'geolocation=(), camera=(), microphone=(), interest-cohort=()');
    response.headers.set('X-Frame-Options', 'SAMEORIGIN');

    if (!response.headers.has('Content-Security-Policy')) {
        response.headers.set('Content-Security-Policy', "frame-ancestors 'self' https://app.storyblok.com");
    }

    return response;
});

Kolejność ma znaczenie. Najpierw await next(), czyli oddanie sterowania dalej i odebranie gotowej odpowiedzi, a dopiero potem dopisanie nagłówków do niej. Odwrotna kolejność nie ma czego modyfikować.

Warunek przy Content-Security-Policy nie jest ozdobnikiem. Dzięki niemu pojedyncza podstrona może ustawić własną, ostrzejszą albo luźniejszą politykę, a middleware jej nie nadpisze. Bez tego warunku każda taka próba kończy się cichym cofnięciem zmiany na ostatnim metrze.

Krótko, co robi każdy z tych nagłówków, bo to pytanie wraca przy odbiorze projektu: Strict-Transport-Security każe przeglądarce łączyć się wyłącznie po HTTPS przez rok. X-Content-Type-Options zabrania zgadywania typu pliku, co zamyka klasę ataków polegających na podrzuceniu skryptu udającego obrazek. Referrer-Policy ogranicza to, ile z adresu Twojej strony wycieka do serwisów zewnętrznych. Permissions-Policy wyłącza dostęp do kamery, mikrofonu i lokalizacji, których strona firmowa nie potrzebuje. X-Frame-Options i dyrektywa frame-ancestors chronią przed osadzeniem strony w cudzej ramce; nowe przeglądarki czytają tę drugą, starsze pierwszy, więc zostawiam oba.

PUŁAPKA: NIEMODYFIKOWALNE NAGŁÓWKI Jeżeli kiedykolwiek będziesz dopisywać nagłówki do odpowiedzi zwróconej wprost przez binding ASSETS, dostaniesz błąd o niemodyfikowalnych nagłówkach. Trzeba wtedy zbudować kopię odpowiedzi, bo dopiero ona jest zapisywalna:
// odpowiedź z bindingu ASSETS ma niemodyfikowalne nagłówki
const res = await env.ASSETS.fetch(request);
const out = new Response(res.body, res); // dopiero na kopii można pisać
out.headers.set('X-Content-Type-Options', 'nosniff');

Druga połowa problemu, o której prawie nikt nie pisze

Tutaj większość poradników się kończy: „przenieś nagłówki do middleware i gotowe". Nie jest gotowe, bo problem jest symetryczny. Middleware uruchamia się tylko dla żądań, które trafiają do Workera. Pliki z katalogu public — obrazy, favicon, robots.txt, pliki do pobrania — serwuje binding statycznych zasobów, z pominięciem Twojego kodu. One nie zobaczą żadnego nagłówka z middleware.

To nie jest teoria. Tak wyglądała ta strona 19 września 2026 roku, zanim dołożyłem brakujący element. Podstrona HTML:

curl -sS -D- -o /dev/null https://twojadomena.pl/

zwracała komplet: strict-transport-security, content-security-policy, cross-origin-opener-policy, permissions-policy, referrer-policy, x-content-type-options, x-frame-options. A plik statyczny pod tą samą domeną:

curl -sS -D- -o /dev/null https://twojadomena.pl/favicon.svg

zwracał content-type, etag, cache-control — i ani jednego nagłówka bezpieczeństwa. Ten sam serwis, ta sama konfiguracja, dwa różne światy. Jeżeli wdrożyłeś samo middleware i uznałeś temat za zamknięty, masz teraz dokładnie ten stan.

Komplet: oba mechanizmy, każdy do swojej połowy

Rozwiązaniem nie jest wybór między _headers a middleware, tylko użycie obu — bo każdy obsługuje tę połowę ruchu, której nie obsługuje drugi. Middleware zostaje bez zmian, a do public/_headers wraca krótki plik obejmujący zasoby statyczne:

/*
  X-Content-Type-Options: nosniff
  Referrer-Policy: strict-origin-when-cross-origin
  Strict-Transport-Security: max-age=31536000; includeSubDomains; preload

Świadomie węższy zestaw niż w middleware. Na plik graficzny nie ma sensu nakładać polityki ramek ani uprawnień do kamery — te dyrektywy dotyczą dokumentu, nie zasobu. Zostaje to, co ma znaczenie przy pliku: zakaz zgadywania typu, ograniczenie wycieku adresu i wymuszenie HTTPS.

Alternatywa, którą znajdziesz w dokumentacji, to ustawienie run_worker_first — kierujące żądania plików statycznych najpierw do Workera, dzięki czemu middleware obejmie także je. Odradzam. Statyczne zasoby serwowane wprost są darmowe, a przepuszczone przez Workera stają się płatnymi wywołaniami. Płacisz wtedy za każde pobranie obrazka po to, żeby dołożyć do niego trzy nagłówki, które plik tekstowy dokłada za zero.

Jak sprawdzić, czy naprawdę działa

Jedna zasada, która oszczędza najwięcej czasu: sprawdzaj zawsze dwa adresy, nie jeden. Jedną podstronę HTML i jeden plik statyczny. Sprawdzenie samej strony głównej daje fałszywe poczucie domknięcia — to właśnie ten adres wygląda dobrze przy wdrożonej połowie rozwiązania.

  • Podstrona: curl -sS -D- -o /dev/null https://twojadomena.pl/
  • Plik statyczny: curl -sS -D- -o /dev/null https://twojadomena.pl/favicon.svg
  • Adres z przekierowaniem — bo przekierowanie też jest odpowiedzią i też powinno nieść HSTS.
  • Podstrona zwracająca 404, jeżeli masz własną — obsługuje ją Worker, więc powinna wyglądać jak zwykła strona.

Lokalnie nie da się tego sprawdzić poleceniem astro dev. Serwer deweloperski nie odtwarza podziału na Workera i zasoby statyczne, więc pokaże obraz niezgodny z produkcją. Użyj wrangler dev na zbudowanym katalogu wynikowym albo sprawdzaj po wdrożeniu.

Czego świadomie nie ustawiam i dlaczego

Na tej stronie Content-Security-Policy ogranicza się do jednej dyrektywy — frame-ancestors. Nie ma pełnej polityki ze script-src i style-src, choć każdy skaner bezpieczeństwa dopisze za to minus. To jest decyzja, nie zaniedbanie.

Pełna polityka na stronie, która ładuje kroje pisma z zewnątrz, ma podpiętą analitykę, osadza podgląd z systemu do zarządzania treścią i renderuje wyspy interaktywne, wymaga albo listy dozwolonych adresów, która rozjeżdża się przy każdej zmianie dostawcy, albo jednorazowych tokenów generowanych przy żądaniu. To drugie jest poprawne i wykonalne — ale to osobny projekt z własnym testowaniem, a nie linijka dopisana przy okazji.

Źle napisana polityka blokuje własne skrypty strony. Klient nie widzi wtedy nagłówka w narzędziach deweloperskich — widzi niedziałający formularz kontaktowy. Nagłówek bezpieczeństwa, który wyłącza konwersję, jest kosztem, nie zabezpieczeniem, i lepiej go nie mieć niż mieć go wdrożonego po łebkach. Wolę zostawić to jawnie opisane niż udawać komplet.

Najczęstsze pytania

Pytania, które dostaję najczęściej przy tym temacie — od osób wdrażających to samodzielnie i od klientów pytających, co właściwie kupili.

Dlaczego plik _headers nie działa na Cloudflare Workers?

Bo obejmuje wyłącznie odpowiedzi ze statycznych zasobów, a nie odpowiedzi wygenerowane przez kod Workera. Dokumentacja Cloudflare mówi to wprost: nagłówki zdefiniowane w _headers nie są stosowane do odpowiedzi tworzonych przez Twojego Workera, nawet jeżeli adres pasuje do reguły z pliku. Projekt w Astro z ustawieniem output: server oddaje każdą podstronę HTML właśnie z kodu Workera, więc plik _headers jej nie dotyka. Widać to natychmiast: plik graficzny dostaje nagłówki, a strona główna nie.

Czy plik _headers jest jeszcze potrzebny, jeśli mam middleware w Astro?

Tak, i to jest część, którą pomija większość poradników. Middleware Astro uruchamia się tylko dla tych żądań, które trafiają do Workera, czyli dla podstron. Pliki z katalogu public — obrazy, favicon, robots.txt, pliki do pobrania — serwuje binding statycznych zasobów, z pominięciem Twojego kodu, i one nie dostają żadnego nagłówka z middleware. Komplet daje dopiero połączenie obu mechanizmów: middleware dla podstron, _headers dla plików statycznych.

Jak sprawdzić, czy nagłówki bezpieczeństwa faktycznie są wysyłane?

Najszybciej poleceniem curl -sS -D- -o /dev/null https://twojadomena.pl/, które wypisze same nagłówki odpowiedzi bez treści strony. Kluczowe jest, żeby sprawdzić dwa adresy osobno: jedną podstronę HTML i jeden plik statyczny, na przykład favicon.svg albo robots.txt. Jeżeli nagłówki są tylko pod jednym z nich, to znaczy, że masz wdrożoną dokładnie połowę rozwiązania — i to jest najczęstszy stan, jaki spotykam.

Czy nagłówki bezpieczeństwa wpływają na pozycje w Google?

Bezpośrednio nie. Jedynym nagłówkiem, który realnie przekłada się na wyszukiwarkę, jest przekierowanie na HTTPS, a HSTS je utrwala. Reszta to warstwa bezpieczeństwa i zaufania, nie rankingu. Warto je mieć z trzech innych powodów: automatyczne audyty i narzędzia do oceny stron je sprawdzają i obniżają wynik, klienci korporacyjni pytają o nie przy odbiorze, a źle napisana polityka Content-Security-Policy potrafi zablokować własne skrypty i wtedy strata w wyszukiwarce jest już całkiem realna.

Gdzie w projekcie Astro umieścić plik _headers?

W katalogu public, czyli public/_headers, bez rozszerzenia. Astro kopiuje zawartość tego katalogu do katalogu wynikowego przy budowaniu, a Cloudflare czyta plik z katalogu statycznych zasobów. Format to blok adresów, pod którym z wcięciem wypisuje się nagłówki. Limity to sto reguł w pliku i dwa tysiące znaków na linię.

Czy run_worker_first rozwiązuje problem nagłówków na plikach statycznych?

Rozwiązuje, ale drogo. Ustawienie run_worker_first kieruje żądania plików statycznych najpierw do Workera, więc middleware może dołożyć nagłówki także im. Kosztem jest to, że każde pobranie obrazka przestaje być darmowym odczytem zasobu statycznego i staje się wywołaniem Workera, które jest liczone i rozliczane. Przy stronie z kilkudziesięcioma plikami graficznymi na podstronę to zauważalna zmiana rachunku za rzecz, którą plik _headers załatwia bez kosztu.

Czym różni się plik _headers na Cloudflare Pages od Workers?

Sam format jest ten sam i to właśnie wprowadza w błąd. Różnica leży w tym, co uchodzi za zasób statyczny. Na Pages klasyczna strona zbudowana statycznie jest plikiem w katalogu wynikowym, więc _headers obejmuje praktycznie wszystko, co widzi użytkownik. Na Workers z renderowaniem po stronie serwera podstrona nie jest plikiem, tylko odpowiedzią programu — i wypada poza zasięg pliku. Większość poradników, które znajdziesz, opisuje układ z czasów Pages i dlatego ich rada przestaje działać bez ostrzeżenia.

Podsumowanie

Cały problem sprowadza się do jednego zdania: na Cloudflare Workers plik _headers obsługuje pliki, a middleware obsługuje podstrony — i żadne z nich nie obsługuje obu. Kto wdroży tylko pierwsze, nie zabezpieczy ani jednej podstrony. Kto wdroży tylko drugie, zostawi bez nagłówków każdy obraz i każdy plik do pobrania. Komplet to oba pliki naraz, a sprawdzian to dwa polecenia curl, nie jedno.

Wpis opisuje przypadek Astro, bo na tym stoi ta strona. Mechanika jest jednak cechą platformy, nie frameworka — w SvelteKit, Nuxt czy Next na tym samym adapterze zmieni się wyłącznie nazwa pliku, w którym dopisujesz nagłówki.

Podobna cicha pułapka dotyczy cache: nagłówek s-maxage na odpowiedzi Workera przez lata niczego nie zapisywał. Opisałem to osobno we wpisie Cache-Control w Astro na Cloudflare Workers.

Jeżeli planujesz stronę i zastanawiasz się, ile kosztuje warstwa, której nie widać — rozbicie kosztów na etapy opisałem we wpisie ile kosztuje strona internetowa, a to, co warto ustalić przed rozmową z wykonawcą, w briefie na stronę internetową. Zakres i widełki dla poszczególnych branż są na stronie usług.

// Warstwa, której nie widać, a od której zależy wynik

Potrzebujesz strony zrobionej porządnie także pod spodem?

Robię strony w Astro na Cloudflare — z poprawnymi nagłówkami, adresami kanonicznymi, mapą strony i danymi strukturalnymi od pierwszego dnia, a nie dokładanymi po audycie. Napisz, co planujesz, a odpiszę z zakresem i widełkami.