Przejdź do treści
Bezpieczeństwo

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.

Redakcjadeveloper front-end10 minkod + tabela

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 odbicia req.headers.origin do odpowiedzi.
  • Lista originów jest w konfiguracji środowiska, a jej brak zatrzymuje start aplikacji.
  • Origin: null nie dostaje zgody.
  • Operacje zapisujące mają własną ochronę — token CSRF albo ciasteczko SameSite z 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.