CORS w Symfony – rozwiążmy problem raz na zawsze

Chcesz rozpocząć nowy projekt w PHP, świetnie! Odpalasz konsolę, za pomocą Composera stawiasz projekt, pierwsze kontrolery do REST API. Za pomocą cURLa albo Postmana strzelasz do endpointu i działa, super! Dalej prosty frontend w Vue albo React, robisz strzał do endpointu i… „No Access-Control-Allow-Origin header is present on the requested resource.„. Czujesz to zirytowanie i niepewność, co zrobiłeś źle. Przecież wszystko działała poprawnie, gdy używasz endpointu spoza aplikacji. Miałem już taki problem, można sobaczyć to tutaj.

Problemem rzecz jasna jest CORS. Właśnie tekst błędu mówi o nim: Cross-Origin Resource Sharing, w wolym tłumaczeniu to między-domenowa wymiana zasobów. I jest to jedno z podstawowych zabezpieczeń wbudowanych w przeglądarkę. Tak, właśnie w przeglądarkę. Dlatego właśnie wykonując zapytanie do endpointu z aplikacji nie działa, a z cURL’a albo Postmana owszem. API działa jak trzeba.

Przeglądarka jednak nie chce, aby użytkownik wpadł w jakieś tarapaty. Bo przecież nasza aplikacja jest w domenie example.com a REST API pod mysuperapi.com. Domeny różne, coś tu śmierdzi. Jeśli jest to zamierzone przez programistę, trzeba o tym poinformować przeglądarkę klienta. Robi się to przez wysyłanie nagłówków z odpowiednimi opcjami.

Protokół HTTP w praktyce

Tak naprawdę mógłby zostawić Cię wiedzą, że to CORS i napisać, jak to rozwiązać. Ale w programowaniu ważne jest rozumienie czemu coś działa tak, a nie inaczej. Zobaczmy to w delikatnym uproszeczeniu.

Protokół HTTP (Hyper-Text Transfer Protocol), to nic innego jak protokół tekstowy, za pomocą którego możemy serwować jakieś dane w postaci właśnie tekstu. Czy to będzie format html czy json, to już swoją drogą. Jeżeli zobaczylibyśmy, co wysyła przeglądarka (zanim zostanie to przekonwertowane na zera i jedynki), to naszym oczom ukaże się podobny widok:

POST /ping HTTP/1.1
Host: mysuperapi.com
Content-Type: application/json

{
    "name":"example"
}

Na co serwer odeśle coś takiego:

HTTP/1.1 200 OK
Server: Ubuntu/Apache
Content-Type: application/json

{
    "name":"example",
    "status":"OK"
}

W pierwszym przypadku jest to zapytanie. Najpierw mamy metodę POST (może być kilka innych, np GET, PUT, PATCH, DELETE). Następnie zasób „/ping”, jak skleimy to z nagłówkiem „Host” niżej, to okaże się, że wysyłamy zapytanie do „mysuperapi.com/ping”. Dalej jest wersja protokołu HTTP, w tym przypadku 1.1.

W drugiej linijce jest po prostu adres hosta, do którego wysyłamy żądanie. Trzecia linijka, to nagłówek Content-Type, mówiący o tym, co przesyłamy do serwera. W naszym wypadku jest to typ JSON. Jeszcze niżej jest już samo ciało własnie w formacie JSON.

Odpowiedź jest niemalże podobna. Różnica jest taka, że w pierwszej linijce mamy kod statusu – 200 OK to nic innego jak sukces.

Nagłówek Server w tym wypadku mówi jaki jest software serwera, w typ wypadku jest to Apache działające na systemie Ubuntu. Swoją drogą często jest to podane z wersją, warto to zmienić ze względów bezpieczeństwa.

W taki właśnie sposób komunikuje się klient z serwerem. Wydaje się proste i nie widać miejsca, gdzie coś się może zblokować. No cóż. Rzecz w tym, że w przypadku ,gdy Host clienta i Host serwera są inne, załączany jest automatycznie w zapytaniu nagłówek Origin, z wartością jaką jest po prostu domena klienta, example.com. Takie zapytanie mogłoby wyglądać tak:

POST /ping HTTP/1.1
Host: mysuperapi.com
Origin: https://example.com
Content-Type: application/json

{
    "name":"example"
}

I cały problem polega na tym, że serwer domyślnie nie odpowie poprawnie, tak jak przeglądarka sobie tego życzy. Mianowicie w tej chwili przeglądarka oczekuje, że otrzyma w odpowiedzi nagłówek Access-Control-cośtam-cośtam. To jest taka grupa nagłówków definiujących właśnie to zabezpieczenie. Jeśli takiego nagłówka nie będzie, otrzymamy błąd. Zobaczmy jednak, jak poprawna odpowiedź mogłaby wyglądać, gdy skonfigurujemy CORS w Symfony:

HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://example.com
Content-Type: application/json

{
    "name":"example",
    "status":"OK"
}

Serwer odpowiedział z nagłówkiem Access-Control-Allow-Origin: https://example.com a więc zgadza się na wszelkie requesty z tej właśnie domeny. A że jest to domena klienta, to przeglądarka jest ukontentowana i przepuszcza taką odpowiedź. Czy obecność nagłówka wystarczy? Otóż nie, musi mieć poprawną wartość. Na przykład:

HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://restricteddomain.com
Content-Type: application/json

{
    "name":"example",
    "status":"OK"
}

Już nie zadziała. Domena klienta nie jest dozwolona, a więc otrzymamy błąd. Warto zaznaczyć, że nagłówek ten może też przyjąć wartość „*”, co oznacza, że kompletnie każdy może wysłać tam zapytanie. Nie muszę chyba mówić, że to nie jest dobry pomysł? 🙂

Jak naprawić problem z CORS w Symfony?

Okej, przejdźmy w końcu do meritum. Tak, jak pisałem, serwer nie wysyła automatycznie nagłówków CORS, trzeba mu w tym pomóc.

Najprościej będzie zainstalować paczę NelmioApiCorsBundle. Jeśli używasz świeżej wersji Symfony (4 lub 5), to wystarczy zrobić

$ composer req cors

W starszych wersjach lub gdy nie używasz Symfony Flex, to:

$ composer require nelmio/cors-bundle

Teraz znajdź config tego pakietu, powinien być w config/packages/nelmio_cors.yml, a w środku coś takiego:

nelmio_cors:
    defaults:
        allow_credentials: false
        allow_origin: []
        allow_headers: []
        allow_methods: []
        expose_headers: []
        max_age: 0
        hosts: []
        origin_regex: false
        forced_allow_origin_value: '*'

Teraz tam, gdzie jest pole allow_origin, wystarczy do przyjmowanej tablicy wpisać nazwę domeny, która jest dozwolona, do komunikacji z naszym API, przykład:

nelmio_cors:
    defaults:
        allow_credentials: false
        allow_origin: []
        allow_headers: []
        allow_methods: []
        expose_headers: []
        max_age: 0
        hosts: []
        origin_regex: false
        forced_allow_origin_value: '*'
    paths:
        "^/":
            allow_origin: ["https://example.com", "https://niceclientoftheapi.com"]
            allow_headers: ["*"]
            allow_methods: ["*"]

Warto się tu też pobawić innymi wartościami. Możemy np. ograniczyć metody, jakie są używane przez klientów, np „GET, POST, OPTIONS” i tak dalej. Wtedy wykonanie np „DELETE” się nie powiedzie.

Mam nadzieję, że tekst ci pomógł w walce i zrozumieniu CORS w Symfony i w ogóle w działaniu. Jeśli tak, napisz komentarz, a może masz jakiś inny problem? Chętnie pomogę!