Dozwolone ścieżki dowiązań symbolicznych

Domyślnie OxPHP odrzuca każde żądanie, które rozwiązuje się do ścieżki poza kanonicznym DOCUMENT_ROOT. Dowiązanie symboliczne wewnątrz DOCUMENT_ROOT wskazujące na zewnętrzny katalog zwraca 404, a rozwiązywanie ścieżki zapisuje w logach Blocked request: resolved path escapes document root.

To właściwe ustawienie domyślne: zatrzymuje przechodzenie po katalogach (directory traversal) oraz przypadkowe ujawnienie plików konfiguracyjnych lub sekretów leżących o poziom wyżej. Blokuje jednak także uzasadnione wzorce, które frameworki stosują od lat: php artisan storage:link w Laravelu, pakiety zasobów Symfony, współdzielone wolumeny przesyłanych plików montowane w wielu kontenerach.

SYMLINK_ALLOW_PATHS to jawna zgoda: wskazujesz ścieżki w systemie plików, do których dowiązania symboliczne pod DOCUMENT_ROOT mogą się rozwiązywać. Wszystko, czego nie ma na liście, zachowuje ścisłe zachowanie z odpowiedzią 404.

Konfiguracja

bash
# Absolute paths, comma-separated SYMLINK_ALLOW_PATHS=/var/www/storage,/opt/shared/assets # Relative paths resolve against DOCUMENT_ROOT SYMLINK_ALLOW_PATHS=../storage,../shared/uploads # Mixed SYMLINK_ALLOW_PATHS=/opt/shared/cdn,../storage/app/public

Gdy zmienna nie jest ustawiona (ustawienie domyślne), żadne dowiązanie symboliczne nie może opuścić DOCUMENT_ROOT.

Przykład dla Laravela

bash
DOCUMENT_ROOT=/app/public SYMLINK_ALLOW_PATHS=../storage/app/public

Wtedy php artisan storage:link tworzy public/storage -> ../storage/app/public wewnątrz projektu. Adresy URL wskazujące na /storage/<file> rozwiązują się przez dowiązanie symboliczne do /app/storage/app/public/<file>, co kanonizuje się do ścieżki, którą lista dozwolonych autoryzuje. Bez żadnej zmiany w kodzie aplikacji.

Jak to działa

Podczas uruchamiania każdy wpis jest rozwiązywany:

  • Wpisy bezwzględne — sprawdzane względem listy blokowania (patrz niżej), następnie przepuszczane przez realpath(3) (czyli std::fs::canonicalize). Jeśli realpath zawiedzie (cel nie istnieje), serwer odmawia uruchomienia.
  • Wpisy względne — łączone z kanonicznym DOCUMENT_ROOT, a następnie poddawane działaniu realpath.

Powstałe kanoniczne ścieżki są zapisywane jako lista dozwolonych. Duplikaty są po cichu usuwane.

W momencie żądania warstwa routingu kanonizuje rozwiązaną ścieżkę pliku i weryfikuje, czy spełnia jeden z warunków:

  1. leży wewnątrz DOCUMENT_ROOT, albo
  2. jest dokładnie równa jednemu z wpisów listy dozwolonych (cele będące plikami), albo
  3. zaczyna się od jednego z wpisów listy dozwolonych, po którym następuje / (cele będące katalogami).

Kontrola routingu buforuje swój werdykt dla każdej ścieżki, dlatego plik, który zostaje ostatecznie odczytany, jest sprawdzany ponownie.

`DOCUMENT_ROOT` za dowiązaniem symbolicznym wydania

DOCUMENT_ROOT jest rozwiązywany raz, przy uruchomieniu, a pliki statyczne odczytywane z dysku i skrypty PHP są sprawdzane względem tej lokalizacji przy każdym ich otwarciu. Katalog główny osiągany przez dowiązanie symboliczne wydania, taki jak /app/current/public w układzie w stylu Capistrano lub Deployer, odpowiada więc 404 zarówno dla plików statycznych, jak i dla skryptów, gdy tylko current zostanie przełączone na nowe wydanie, aż do restartu serwera. Pliki serwowane z bufora w pamięci nadal odpowiadają przechowywanymi w nim bajtami. Ustaw DOCUMENT_ROOT na ścieżkę, która nie zmienia się między wydaniami, albo restartuj serwer w ramach przełączenia.

Pliki statyczne

W przypadku plików statycznych kontrola uruchamiana jest po raz drugi w chwili serwowania, na faktycznie otwartym pliku i zanim zostanie odczytany jakikolwiek jego bajt. Lokalizacja jest pobierana z otwartego deskryptora (/proc/self/fd/N na Linuksie, F_GETPATH na macOS), więc dowiązanie symboliczne podmienione po zbuforowanej kontroli routingu i tak zostaje odrzucone. Tam, gdzie ścieżka deskryptora jest niedostępna, ścieżka jest rozwiązywana ponownie i musi wskazywać ten sam plik, który został otwarty.

Note

Plik obsłużony z bufora w pamięci nie jest ponownie otwierany. Podmienione dowiązanie nadal zwraca bajty zbuforowane z jego wcześniejszego, zweryfikowanego celu, dopóki wpis nie zostanie usunięty z bufora albo, przy STATIC_REVALIDATE=on, dopóki rewalidacja nie zauważy zmiany.

Skrypty PHP

Skrypty PHP są traktowane tak samo w każdym trybie z wyjątkiem trybu worker, którego plik wejściowy to ścieżka skonfigurowana przez administratora. Worker sam otwiera skrypt, w ten sam sposób sprawdza lokalizację otwartego deskryptora i przekazuje silnikowi ten otwarty plik zamiast ścieżki, więc otwierany plik jest tym samym plikiem, który został sprawdzony. Jest jeden wyjątek: skrypt, którego rozwiązana nazwa zawiera .phar i który jest archiwum phar w formacie zip, tar lub skompresowanym, zostaje otwarty po raz drugi po nazwie przez rozszerzenie phar, a to drugie otwarcie nie jest objęte kontrolą.

Skrypt w chwili serwowania Wynik
Nie jest zwykłym plikiem (katalog, FIFO) albo istnieje, ale nie da się go odczytać 500, w logach Cannot open script. Własne ostrzeżenie PHP o nieudanym otwarciu ani błąd krytyczny nie są generowane.
Usunięty od czasu routingu 404, również dla skryptu przechowywanego przez OPcache, który przy wyłączonym opcache.validate_timestamps zostałby w przeciwnym razie uruchomiony z pamięci podręcznej bez otwierania pliku
Dowiązanie przekierowane po routingu na skrypt albo na plik tekstowy, który silnik wypisałby bez zmian 404, w logach Blocked request: opened script escapes document root
Dowiązanie przekierowane na inny skrypt wewnątrz DOCUMENT_ROOT albo na cel z listy dozwolonych Uruchamia ten skrypt

__FILE__, __DIR__ i $_SERVER['SCRIPT_FILENAME'] są takie same jak bez tej kontroli. Ponieważ silnik otrzymuje rozwiązaną lokalizację pliku, wynikają z tego trzy rzeczy:

  • OPcache przechowuje skrypt osiągnięty przez dowiązanie pod plikiem, do którego to dowiązanie się rozwiązuje.
  • Katalogiem roboczym skryptu jest katalog tego pliku. W przypadku dowiązania do pliku w innym katalogu nie jest to katalog, w którym leży dowiązanie.
  • Rozszerzenie phar na podstawie tej rozwiązanej nazwy decyduje, czy skrypt jest archiwum phar. Dowiązanie o nazwie app.phar.php, które wskazuje na phar oparty na zip, którego własna nazwa nie zawiera .phar, jest kompilowane jako zwykły plik, a dowiązanie bez .phar w nazwie, wskazujące na phar, którego nazwa je zawiera, jest uruchamiane jako phar.
Dołączane pliki nie są sprawdzane

Kontrola obejmuje skrypt, do którego zostało skierowane żądanie. Pliki, które ten skrypt wciąga przez include lub require albo otwiera po nazwie, a także auto_prepend_file, PHP rozwiązuje jak zwykle. Tam, gdzie skrypty lub ich katalogi są zapisywalne przez kod, któremu nie ufasz, dodaj również open_basedir.

open_basedir dotyczy także samego skryptu, do którego skierowano żądanie: skrypt spoza niego jest odrzucany tak, jak PHP odrzuca każdy skrypt, którego nie może otworzyć, ze statusem 500 i zwykłymi ostrzeżeniami open_basedir restriction in effect. Jest sprawdzany, gdy PHP kompiluje skrypt, w tym samym momencie co dla skryptu, który PHP otwiera sam: po wykonaniu auto_prepend_file, z katalogiem skryptu jako katalogiem roboczym. Względny wpis, taki jak open_basedir=., oznacza więc katalog skryptu, a open_basedir, które plik dołączany na początku zawęża przez ini_set(), dotyczy skryptu, przed którym jest on dołączany. Obejmuje to także skrypt, który OPcache już przechowuje, a który PHP uruchomiłby bez sprawdzania.

Lista blokowania

Niewielki zbiór ścieżek nigdy nie może pojawić się w SYMLINK_ALLOW_PATHS; literówki i nieporozumienia w przeciwnym razie drastycznie poszerzyłyby powierzchnię ataku. Serwer odmawia uruchomienia, jeśli którykolwiek wpis rozwiązuje się do ścieżki z listy blokowania.

Zabronione jako dokładne dopasowanie:

text
/ /etc /proc /sys /dev /var /home /tmp /root /usr /srv

Zabronione jako prefiks (wpis leży pod jednym z tych katalogów):

text
/etc /proc /sys /dev /tmp /root /usr
Note

/var, /home i /srv są dozwolone wyłącznie jako dokładne dopasowanie: samo /srv jest odrzucane, ale /srv/myapp/storage jest dozwolone, tak samo jak dozwolone są /var/www/storage oraz /home/<any>/.... Wpisy są sprawdzane dwukrotnie: raz względem surowej ścieżki podanej przez administratora (tak aby ścieżka w stylu macOS /etc -> /private/etc nie mogła przemycić zablokowanej ścieżki przez realpath), raz względem postaci kanonicznej (obrona w głąb przeciwko ucieczkom celu dowiązania symbolicznego).

Sama lista blokowania jest zaszyta w kodzie; nie ma zmiennej środowiskowej, która by ją rozszerzała. Ustawienie domyślne to konserwatywne minimum wychwytujące błędy na poziomie literówek; administratorzy potrzebujący surowszych zasad powinni nakładać je z zewnątrz (uprawnienia systemu plików, ograniczenia montowania w kontenerach, profile AppArmor/SELinux).

Tryby awaryjne

Błąd konfiguracji Wynik
Cel wpisu nie istnieje na dysku Serwer odmawia uruchomienia, błąd wskazuje wpis oraz canonicalize
Wpis pasuje do listy blokowania (surowy lub kanoniczny) Serwer odmawia uruchomienia, błąd wskazuje wpis oraz regułę listy blokowania
Pusta zmienna środowiskowa lub zawierająca tylko białe znaki Traktowana jako nieustawiona — ścisłe zachowanie domyślne
Zduplikowane wpisy Po cichu usuwane po kanonizacji
Podczas uruchamiania nie istnieje jeszcze żadne dowiązanie symboliczne Lista dozwolonych jest zarejestrowana, ale bezczynna do momentu pojawienia się dowiązania symbolicznego; żadna kontrola przy uruchamianiu nie wymaga dowiązania

Uwagi dotyczące bezpieczeństwa

  • Lista dozwolonych działa na zasadzie jawnej zgody — bezpieczne ustawienie domyślne „bez ucieczek" jest zachowane, gdy zmienna nie jest ustawiona
  • Wpisy są kanonizowane podczas uruchamiania, więc .. oraz pośrednie dowiązania symboliczne w ścieżce wpisu są zwijane przed zapisaniem
  • Pliki statyczne i skrypty PHP są sprawdzane na otwartym pliku, więc na Linuksie z zamontowanym /proc oraz na macOS podmiana dowiązania symbolicznego po tym, jak zostało ono raz obsłużone, nie może sprawić, że późniejsza odpowiedź będzie pochodzić spoza dozwolonych lokalizacji ani że zostanie stamtąd uruchomiony skrypt. Mechanizm zastępczy używany w innych przypadkach jest węższy, jak opisano wyżej
  • Cele będące plikami dopasowywane są dokładnie; cele będące katalogami dopasowywane są po prefiksie katalogu. Umieszczenie na liście pliku /etc/passwd i tak zostałoby odrzucone przez listę blokowania, ale bardziej ogólnie: umieszczenie na liście pojedynczego pliku /opt/shared/license.key nie daje niejawnie dostępu do plików sąsiednich
  • Walidacja w czasie routingu jest buforowana per żądany adres URL (jeden realpath na unikalny adres URL do momentu usunięcia z pamięci podręcznej). Kontrola w chwili serwowania dodaje odczyt lokalizacji otwartego deskryptora dla każdego pliku statycznego czytanego z dysku i nic dla pliku serwowanego z bufora w pamięci. Dla skryptu PHP poza trybem worker dodaje około ośmiu wywołań systemowych do każdego żądania (policzonych za pomocą strace na PHP 8.4): open i close skryptu, ten odczyt lokalizacji oraz wywołania fstat, fcntl i lseek na deskryptorze. Dotyczy to również żądania obsłużonego przez OPcache, które przy wyłączonym opcache.validate_timestamps w ogóle nie dotknęłoby pliku. Na skrypcie wypisującym jedno słowo zmierzono koszt około 3% przepustowości; prawdziwa aplikacja poświęca na to proporcjonalnie mniejszą część swojego żądania

Prompty dla Twojego agenta

Te prompty zakładają agenta z podłączonym serwerem MCP dokumentacji OxPHP — sam przeczyta strony, których potrzebuje.

prompt
Skonfiguruj `SYMLINK_ALLOW_PATHS` dla dowiązań symbolicznych do storage, od których zależy moja aplikacja. Przeczytaj stronę `security/symlink-allow-paths` przez serwer MCP dokumentacji OxPHP. Znajdź dowiązania symboliczne pod moim katalogiem głównym dokumentów — takie jak dowiązanie w stylu `storage:link` — oraz ich rzeczywiste cele, a potem podaj mi wpisy listy dozwolonych i oznacz każdy cel, który trafiłby na listę blokowania.

Zobacz także

Znalazłeś błąd? Zgłoś go →