Obca strona odczytała e-mail, NIP i saldo z zalogowanej sesji. W konfiguracji CORS stoi origin: true
Pakiet cors z origin: true odbija każdy Origin, także null. Pomiar czterech konfiguracji: co obca strona odczytała z sesji i czego CORS nie blokuje.
Strona postawiona pod obcym adresem odczytała z zalogowanej sesji e-mail, NIP i saldo klienta — całe body odpowiedzi, nie fragment. Endpoint był ten sam, ciasteczko sesji to samo, serwer ten sam. Różnica siedziała w jednej linii konfiguracji backendu: origin: true zamiast listy dozwolonych adresów.
To nie jest egzotyczne ustawienie. Tak wygląda typowa droga naprawy błędu CORS w konsoli: model generuje endpoint z gwiazdką, front z ciasteczkiem sesji dostaje błąd, ktoś dokłada credentials: true, błąd zostaje, więc gwiazdka zamienia się na true — i wtedy konsola milknie. Problem w tym, że milknie, bo przeglądarka dostała zgodę na odczyt dla każdego adresu, który zapyta.
Warunki pomiaru
Stanowisko: Node.js 22.23.4, Express 4.21.2, pakiet cors 2.8.5, Chromium 153.0.8010.12 uruchomiony headless przez Playwright 1.63. Dwa hosty po HTTPS z certyfikatem własnym: api.local:3001 to API z sesją, obcy.local:3002 to strona pod innym originem. Ciasteczko sesji ustawiane jako HttpOnly; SameSite=None; Secure — do tego, dlaczego ten jeden atrybut rozstrzyga o całej sprawie, wracam w dalszej części.
Strona z obcego originu robiła dwa żądania z credentials: 'include': GET /api/me (dane sesji) oraz POST /api/profil z Content-Type: text/plain (zapis). Serwer liczył wykonane zapisy w pamięci, niezależnie od tego, co widziała przeglądarka. Cztery konfiguracje middleware, ta sama strona atakująca.
| Konfiguracja | Nagłówek ACAO dla obcego originu | Co odczytała obca strona | Zapisów wykonanych na serwerze |
|---|---|---|---|
cors() |
* |
nic — TypeError: Failed to fetch |
1 |
cors({ origin: '*', credentials: true }) |
* plus ACAC: true |
nic — TypeError: Failed to fetch |
1 |
cors({ origin: true, credentials: true }) |
https://obcy.local:3002 |
pełne body: id, e-mail, NIP, saldo | 1 |
allowlista plus credentials: true |
brak nagłówka | nic — TypeError: Failed to fetch |
1 |
Kolumna z zapisami jest tu najciekawsza i wrócę do niej osobno: jest w niej jedynka w każdym wierszu, również tam, gdzie odczyt był zablokowany.
cors() bez argumentów to konfiguracja publicznego API, nie endpointu z sesją
Domyślne ustawienia pakietu są wypisane w jego dokumentacji konfiguracji: origin: "*", methods: "GET,HEAD,PUT,PATCH,POST,DELETE", preflightContinue: false, optionsSuccessStatus: 204. Klucza credentials w domyślnym obiekcie nie ma wcale, więc nagłówek Access-Control-Allow-Credentials nie wychodzi z serwera.
Pomiar nagłówków to potwierdza: dla każdego podstawionego Origin, w tym dla wartości null, odpowiedź miała Access-Control-Allow-Origin: * i nie miała Vary. Preflight na PUT wracał ze statusem 204 i listą sześciu metod.
Dla API, które nie czyta ciasteczka ani nagłówka autoryzacji, jest to poprawne ustawienie. Dla endpointu z sesją oznacza, że front po prostu nie dostanie danych: dokumentacja MDN opisuje to jednoznacznie — bez nagłówka Access-Control-Allow-Credentials: true odpowiedź na żądanie z poświadczeniem jest przez przeglądarkę ignorowana i nie trafia do kodu strony.
Warto wiedzieć, skąd ten wzorzec bierze się w wygenerowanym kodzie. Przykład CORS w dokumentacji route handlerów Next.js pokazuje dokładnie 'Access-Control-Allow-Origin': '*' razem z listą metod i nagłówków. Jako przykład techniczny jest w porządku. Jako wzorzec przeniesiony do trasy, która odczytuje ciasteczko sesji, już nie — a model nie rozróżnia tych dwóch sytuacji, bo w promcie zwykle nikt mu nie powiedział, że endpoint jest uwierzytelniony.
Gwiazdka z credentials: true nie działa, i w tym miejscu powstaje dziura
Druga zmierzona konfiguracja to cors({ origin: '*', credentials: true }). Serwer wystawił oba nagłówki naraz: Access-Control-Allow-Origin: * i Access-Control-Allow-Credentials: true. Przeglądarka i tak zablokowała odczyt — TypeError: Failed to fetch. Zgodnie z opisem CORS w MDN serwer przy żądaniu z poświadczeniem nie może podać gwiazdki, tylko konkretny origin; jeśli poda gwiazdkę, przeglądarka blokuje dostęp do odpowiedzi i wypisuje błąd w konsoli.
I tu zaczyna się właściwy problem, bo z perspektywy osoby naprawiającej błąd sytuacja wygląda następująco: dodałem credentials: true, błąd nie zniknął, więc winna jest gwiazdka. Następny krok jest o jedną literę od poprawnego i o cały rząd wielkości od bezpiecznego.
origin: true odbija każdy Origin, także null
Dokumentacja pakietu opisuje wartość true wprost: odbija nagłówek Origin z żądania do nagłówka Access-Control-Allow-Origin. Nie jest to „pozwól własnemu frontowi”, tylko „pozwól temu, kto pyta”. Pomiar: dla Origin: https://panel.local:4000 wróciło https://panel.local:4000, dla Origin: https://obcy.local:3002 wróciło https://obcy.local:3002, w obu przypadkach z Access-Control-Allow-Credentials: true i z poprawnie dopisanym Vary: Origin.
Trzeci podstawiony origin to null — i tu odpowiedź brzmiała Access-Control-Allow-Origin: null. To nie jest przypadek teoretyczny. Wartość null w nagłówku Origin wysyłają między innymi dokumenty w ramce z atrybutem sandbox i treści otwarte z lokalnego pliku. Jeśli ktoś zabezpieczył endpoint warunkiem „origin musi być ustawiony”, ten warunek przechodzi.
W Chromium ten pomiar skończył się odczytaniem całego JSON-a z sesji przez stronę obcego originu. Dane, które wyszły: identyfikator użytkownika, adres e-mail, NIP i saldo. Z punktu widzenia przeglądarki wszystko odbyło się zgodnie z regułami — serwer wyraził zgodę.
Blokada dotyczy odczytu, nie wykonania. POST wszedł we wszystkich czterech wariantach
Licznik zapisów po stronie serwera pokazał 1 w każdej z czterech konfiguracji, w tym w obu, gdzie przeglądarka zgłosiła Failed to fetch. Żądanie POST z Content-Type: text/plain spełnia warunki żądania prostego, więc nie jest poprzedzone preflightem: leci na serwer od razu, razem z ciasteczkiem, a przeglądarka jedynie ukrywa odpowiedź przed kodem strony.
Praktyczny wniosek jest taki, że konfiguracja CORS nie jest ochroną przed zapisem i nie zastępuje tokenu CSRF. Chroni to, co obca strona może przeczytać. Operacja, która zmienia dane i nie potrzebuje odpowiedzi, przechodzi mimo najostrzejszej allowlisty. Jeśli w wygenerowanym backendzie jedyną warstwą obrony przed żądaniami z cudzej strony jest middleware CORS, warstwy obrony nie ma.
Czy luka jest wykonalna, rozstrzyga atrybut ciasteczka, nie konfiguracja CORS
Ta sama konfiguracja origin: true, credentials: true, ta sama strona atakująca, zmieniony jeden atrybut przy ustawianiu ciasteczka:
| Ciasteczko sesji | Status GET z obcego originu | Co wyszło | Zapisów na serwerze |
|---|---|---|---|
SameSite=None; Secure |
200 | id, e-mail, NIP, saldo | 1 |
SameSite=Lax |
401 | {"blad":"brak sesji"} |
0 |
Przy SameSite=Lax przeglądarka nie dołożyła ciasteczka do żądania z cudzej strony, więc serwer odpowiedział 401 i zapis się nie wykonał — mimo że CORS był otwarty na oścież. Atrybut SameSite ma dziś domyślną wartość restrykcyjną, więc samo origin: true w projekcie, w którym front i API siedzą na tej samej domenie, nie wystarczy do wycieku.
Tylko że CORS konfiguruje się właśnie wtedy, gdy front i API są na różnych domenach. A w tym układzie ciasteczko sesji musi mieć SameSite=None, żeby cokolwiek działało. Oba warunki luki spełniają się więc w tym samym projekcie, i to nie przez przypadek, a przez architekturę.
Osobny przypadek: token w nagłówku Authorization trzymany w localStorage. Tym kanałem obca strona nie wyciągnie danych, bo localStorage jest przypisany do originu i obcy skrypt go nie przeczyta. To jednak nie jest argument za trzymaniem tokenów w localStorage — przenosi tylko ryzyko na XSS, gdzie skutek jest ten sam, a droga krótsza.
Sprawdzenie u siebie: trzy komendy i dwie pułapki
Pomiar nie wymaga narzędzi. Podstawiasz własny Origin i patrzysz, co wraca:
# 1. żądanie z adresu, którego nie ma na allowliście
curl -s -D - -o /dev/null https://api.twojprojekt.pl/api/me \
-H "Origin: https://przyklad-obcy.test"
# 2. to samo z Origin: null (ramka z sandbox, plik lokalny)
curl -s -D - -o /dev/null https://api.twojprojekt.pl/api/me \
-H "Origin: null"
# 3. preflight dla metody zapisującej
curl -s -D - -o /dev/null -X OPTIONS https://api.twojprojekt.pl/api/profil \
-H "Origin: https://przyklad-obcy.test" \
-H "Access-Control-Request-Method: PUT" \
-H "Access-Control-Request-Headers: content-type"
Interesują cię trzy nagłówki w odpowiedzi: Access-Control-Allow-Origin, Access-Control-Allow-Credentials i Vary. Jeśli w pierwszej albo drugiej komendzie dostajesz z powrotem podstawiony adres lub null, a obok Access-Control-Allow-Credentials: true, masz tę samą konfigurację, która w pomiarze oddała dane sesji.
Pierwsza pułapka: w wariancie z allowlistą preflight dla niedozwolonego originu wrócił ze statusem 200, nie 403. Pakiet po prostu nie dołożył nagłówków CORS i puścił żądanie dalej. Jeżeli testujesz konfigurację, patrząc na kod odpowiedzi, zobaczysz dwieście i uznasz, że przeszło. Status nic tu nie znaczy — znaczenie ma obecność nagłówków.
Druga pułapka: odczyt response.headers.get('access-control-allow-origin') z poziomu JavaScriptu zwraca null nawet wtedy, gdy nagłówek w odpowiedzi jest. Nie należy do nagłówków wystawianych skryptowi domyślnie, więc konsola przeglądarki nie jest tu dobrym przyrządem. Mierz curlem albo zakładką z żądaniami w narzędziach deweloperskich.
Konfiguracja, którą można oddać klientowi
Allowlista z jawnie wymienionych adresów, bez wartości domyślnej, która przepuszcza wszystko:
const dozwolone = new Set(
(process.env.CORS_ORIGINS || '').split(',').map((s) => s.trim()).filter(Boolean)
);
if (dozwolone.size === 0) {
throw new Error('CORS_ORIGINS nie jest ustawione - brak listy dozwolonych originow');
}
app.use(cors({
origin: (origin, cb) => {
if (!origin) return cb(null, false); // brak Origin i "null" odpadaja
cb(null, dozwolone.has(origin));
},
credentials: true,
methods: ['GET', 'POST'], // tylko metody, ktore ta trasa obsluguje
maxAge: 600,
}));
Trzy rzeczy, które w tym kodzie robią robotę. Po pierwsze: brak zmiennej środowiskowej wywala aplikację przy starcie, zamiast cicho zdegradować się do trybu „wpuszczaj wszystkich” — to różnica między awarią wdrożenia a luką, której nikt nie zauważy. Po drugie: porównanie pełnego ciągu originu, nie includes ani wyrażenia regularnego z kropką bez escapowania; wzorzec dopasowujący „kończy się na twojadomena.pl” przechodzi dla twojadomena.pl.przyklad-obcy.test. Po trzecie: lista metod zawężona do tych, które trasa faktycznie obsługuje, a nie domyślna szóstka z PUT, PATCH i DELETE. Wszystkie trzy punkty dotyczą zakresu nadanej zgody, a nie tego, czy zgoda w ogóle jest — ta sama klasa błędu wraca przy uprawnieniach, jakie dostaje integracja czytająca dane z CRM, gdzie dostęp nadany „do odczytu” obejmuje też rekordy, których po drugiej stronie nikt nie zamierzał pokazywać.
Jeśli nagłówki ustawiasz ręcznie, poza pakietem, dopisz Vary: Origin samodzielnie. MDN zaleca ten nagłówek przy odpowiedziach zależnych od originu, a przy CDN-ie przed aplikacją nie jest to kosmetyka: bez niego pośrednik może oddać jednemu klientowi odpowiedź z nagłówkiem Access-Control-Allow-Origin wystawionym dla kogoś innego. Pakiet cors dopisuje Vary sam przy originie dynamicznym — co pomiar potwierdził — ręczny kod w route handlerze zwykle nie.
Przed oddaniem projektu warto przejść tę listę:
- W repo nie ma
origin: true,origin: '*'przy trasach z sesją ani odbiciareq.headers.origindo odpowiedzi. - Lista originów jest w konfiguracji środowiska, a jej brak zatrzymuje start aplikacji.
Origin: nullnie dostaje zgody.- Operacje zapisujące mają własną ochronę — token CSRF albo ciasteczko
SameSitez wartością inną niżNone— bo CORS zapisu nie zatrzymuje. - W CI stoi test, który strzela w endpoint z podstawionym obcym originem i oczekuje braku nagłówka
Access-Control-Allow-Origin; bez tego konfiguracja odbuduje się przy pierwszym „u mnie nie działa”. - Protokół odbioru u klienta wymienia adresy, które mają dostęp — zmiana domeny frontu to zmiana konfiguracji API, nie detal wdrożeniowy.
Ostatni punkt jest też punktem rozliczeniowym: jeżeli przeglądy tego typu robisz na zlecenie, warto mieć na nie osobną pozycję w wycenie, a nie wrzucać je w „poprawki po odbiorze”. W społeczności i kursach dla osób pracujących na zleceniach rozkładanie takich prac na pozycje oferty wraca jako temat regularnie, bo właśnie na nich najłatwiej przepracować kilkanaście nierozliczonych godzin.
Decyzja do podjęcia jest prosta i zajmuje pięć minut: uruchom trzy komendy z sekcji powyżej na API projektu, który teraz oddajesz. Jeśli wróci podstawiony przez ciebie adres razem z Access-Control-Allow-Credentials: true, nie dopisuj tego do backlogu — to jest zgoda na odczyt danych sesji wystawiona każdemu, kto zapyta, a zdjęcie jej to jedna linia w konfiguracji, nie refaktor.