CORS (#7) – API w PHP z Laminas Mezzio

tytuł wpisu z grafiką kodu

Same-Origin Policy to norma w dzisiejszych przeglądarkach internetowych. Odpowiedzialna jest za bezpieczną wymianę zasobów i komunikację między dwoma aplikacjami. Chociaż w swoich założeniach obejmuje dość szeroki zakres to w praktyce, aktualnie, sprowadza się to do blokowania żądań wysyłanych z poziomu przeglądarki (AJAX) do API pod inną domeną (w uproszczeniu, bo w rzeczywistości chodzi o origin, na który składa się protokół, host i port). Mechanizm, który przez wielu uważany jest za zbędny, a już na pewno irytujący. Kiedy jednak zostanie prawidłowo zrozumiany, okazuje się że nie jest taki głupi.

Jaki więc spełnia cel? Nawet jeśli nie widać zagrożenia wynikającego z możliwości wykonania zapytania z poziomu dowolnej domeny (bo przecież API jest odpowiednio autoryzowane) to istnieją innego rodzaju ataki. Głównie chodzi o CSRF (Cross-Site Request Forgery), który pozwala wykorzystać użytkownika posiadającego uprawnienia do wykonania żądania tak by je nieświadomie uskutecznił. Warto też dodać, że sam CSRF zostanie jedynie ograniczony przez SOP. W połączeniu z podatnością typu XSS (Cross-Site Scripting) nadal istnieje opcja jego wykorzystania. W praktyce nie powinno to więc być jedyne zabezpieczenie przed tym atakiem – zazwyczaj dokłada się jeszcze token.

Trzeba jednak przyznać, że polityka SOP jest zbyt restrykcyjna. Nie ma możliwości tworzenia nowoczesnych aplikacji bez komunikacji między różnymi serwisami. Na szczęście z pomocą przychodzi tytułowy CORS (Cross-Origin Resource Sharing), czyli mechanizm, który pozwala na kontrolowany dostęp do zasobów innej domeny. Dzięki niemu można zdefiniować originy, które otrzymają dostęp do zasobu i reprezentują zaufane domeny.

Jak działa CORS z poziomu przeglądarki? Narzędzie wykonuje tak zwany preflight request, który sprawdzi, czy z danego originu komunikacja jest możliwa. Implementacja jest już gotowa, więc dzieje się to automatycznie i nie wymaga żadnego dodatkowego kodu po stronie aplikacji. Jedną z możliwości jest wildcard (*), czyli czyli całkowite pozbycie się polityki Same-Origin Policy i pozwolenie każdemu na wykonywanie żądań XHR. Jeśli ktoś podejmuje taką decyzję świadomie i faktycznie jest to niezbędne to w porządku, ale częściej jednak powinno się to sprowadzać do kontrolowanych dostępów. Tak zresztą będzie w tej aplikacji.

Implementacja CORS w PHP z Mezzio

Tym razem więcej teorii, bo kodu będzie bardzo mało. Do obsługi Cross-Origin Resource Sharing wykorzystany zostanie gotowy moduł z pakietu Mezzio. Jego instalację można zrealizować za pomocą poniższej komendy.

composer require mezzio/mezzio-cors

Automatycznie, bądź samodzielnie trzeba dorzucić providera odpowiedzialnego za konfigurację w pliku config/config.php. Następnie zarejestrować middleware realizujący CORS w pliku config/pipeline.php. Dodany zostaje tuż pod tym odpowiedzialnym za obsługę błędów. Obie czynności zostały zaznaczone w poniższych blokach kodu.

<?php

declare(strict_types=1);

use Laminas\ConfigAggregator\ArrayProvider;
use Laminas\ConfigAggregator\ConfigAggregator;
use Laminas\ConfigAggregator\PhpFileProvider;

$cacheConfig = [
    'config_cache_path' => 'data/cache/config-cache.php',
];

$aggregator = new ConfigAggregator([
    Mezzio\Cors\ConfigProvider::class,
    Laminas\InputFilter\ConfigProvider::class,
    Laminas\Filter\ConfigProvider::class,
    Laminas\Validator\ConfigProvider::class,
    Mezzio\Authentication\Basic\ConfigProvider::class,
    Mezzio\Authentication\ConfigProvider::class,
    Mezzio\Router\FastRouteRouter\ConfigProvider::class,
    Laminas\HttpHandlerRunner\ConfigProvider::class,
    new ArrayProvider($cacheConfig),
    Mezzio\Helper\ConfigProvider::class,
    Mezzio\ConfigProvider::class,
    Mezzio\Router\ConfigProvider::class,
    Laminas\Diactoros\ConfigProvider::class,
    Error\ConfigProvider::class,
    Auth\ConfigProvider::class,
    Store\ConfigProvider::class,
    Customer\ConfigProvider::class,
    new PhpFileProvider(realpath(__DIR__) . '/autoload/{{,*.}global,{,*.}local}.php'),
    new PhpFileProvider(realpath(__DIR__) . '/development.config.php'),
], $cacheConfig['config_cache_path']);

return $aggregator->getMergedConfig();

<?php

declare(strict_types=1);

use Auth\Middleware\IpAccessControlMiddleware;
use Laminas\Stratigility\Middleware\ErrorHandler;
use Mezzio\Application;
use Mezzio\Authentication\AuthenticationMiddleware;
use Mezzio\Cors\Middleware\CorsMiddleware;
use Mezzio\Handler\NotFoundHandler;
use Mezzio\Helper\BodyParams\BodyParamsMiddleware;
use Mezzio\Helper\ServerUrlMiddleware;
use Mezzio\Helper\UrlHelperMiddleware;
use Mezzio\MiddlewareFactory;
use Mezzio\Router\Middleware\DispatchMiddleware;
use Mezzio\Router\Middleware\ImplicitHeadMiddleware;
use Mezzio\Router\Middleware\ImplicitOptionsMiddleware;
use Mezzio\Router\Middleware\MethodNotAllowedMiddleware;
use Mezzio\Router\Middleware\RouteMiddleware;
use Psr\Container\ContainerInterface;

return function (Application $app, MiddlewareFactory $factory, ContainerInterface $container): void {
    $app->pipe(ErrorHandler::class);

    $app->pipe(CorsMiddleware::class);

    $app->pipe(ServerUrlMiddleware::class);
    $app->pipe('/api/v1', AuthenticationMiddleware::class);
    $app->pipe('/api/v1', IpAccessControlMiddleware::class);
    $app->pipe(RouteMiddleware::class);

    $app->pipe(ImplicitHeadMiddleware::class);
    $app->pipe(ImplicitOptionsMiddleware::class);
    $app->pipe(MethodNotAllowedMiddleware::class);

    $app->pipe(BodyParamsMiddleware::class);
    $app->pipe(UrlHelperMiddleware::class);

    $app->pipe(DispatchMiddleware::class);

    $app->pipe(NotFoundHandler::class);
};

Jeśli chodzi o konfigurację samego modułu to już wszystko. Teraz czas na implementację, ale okazuje się że moduł nie wymaga żadnego dodatkowego kodu. Jedyne co to prostą konfigurację. Prostą przynajmniej w tym przypadku, bo oczywiście CORS może być definiowany bardzo szeroko i nawet narzucać różne zasady dla różnych końcówek API. Jak zwykle odsyłam do dokumentacji biblioteki.

<?php

declare(strict_types=1);

use Mezzio\Cors\Configuration\ConfigurationInterface;

return [
    ConfigurationInterface::CONFIGURATION_IDENTIFIER => [
        'allowed_headers' => ['Authorization'],
        'allowed_max_age' => '3600',
        'credentials_allowed' => true,
        'exposed_headers' => []
    ],
];
<?php

declare(strict_types=1);

use Mezzio\Cors\Configuration\ConfigurationInterface;

return [
    ConfigurationInterface::CONFIGURATION_IDENTIFIER => [
        'allowed_origins' => ['https://koddlo.pl', 'https://*.local']
    ]
];

Powyżej znajdują się dwa pliki. Jeden to config/autoload/cors.global.php z konfiguracją dla całej aplikacji, a drugi to config/autoload/cors.local.php z konfiguracją dla odpowiedniego środowiska. Oczywiście ten drugi nie trafia do repozytorium kodu – jedynie jego szablon. Dzięki takiemu rozwiązaniu można definiować zaufane domeny pod konkretne środowisko i ograniczyć je do minimum. Dodatkowo w przypadku potrzeby dodania nowej, wystarczy ją uwzględnić w pliku – nie trzeba wprowadzać zmian w repozytorium kodu.

Krótko jeszcze o opcjach konfiguracyjnych:

  • allowed_origins: definiuje dozwolone originy po przecinku,
  • allowed_headers: definiuje dozwolone nagłówki przesyłane w żądaniu do serwera,
  • allowed_max_age: określa, jak długo preflight request może żyć w cache,
  • credentials_allowed: określa, czy mogą być przesyłane dane dostępowe,
  • exposed_headers: definiuje dozwolone nagłówki przesyłane w odpowiedzi serwera (poza kilkoma, które są w puli domyślnie).

Podsumowanie – CORS w Laminas Mezzio

Nie uwierzycie, ale CORS już działa. Teraz żądania z poziomu przeglądarki do API z bazą klientów będą możliwe tylko ze zdefiniowanych źródeł. SOP i CORS to dość uciążliwe mechanizmy, ale pomagające podnieść jakość bezpieczeństwa aplikacji, więc nie warto z nich rezygnować. Zresztą jak widzicie, implementacja mechanizmu po stronie serwera jest niezwykle prosta – przynajmniej w tak podstawowej wersji jak w tym wpisie.

Zachęcam do przeczytania jeszcze więcej o tym zagadnieniu. Dwa dość obszerne wpisy znajdziecie w serwisach Sekurak i Mansfeld.

Cały kod dostępny jako gałąź step07.

Krystian Żądło
Programista PHP i właściciel marki Koddlo. Pasjonat czystego kodu i dobrych praktyk programowania obiektowego. Prywatnie fan angielskiej piłki nożnej, dobrego humoru oraz podcastów.