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
# 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/publicGdy zmienna nie jest ustawiona (ustawienie domyślne), żadne dowiązanie symboliczne nie może opuścić DOCUMENT_ROOT.
Przykład dla Laravela
DOCUMENT_ROOT=/app/public
SYMLINK_ALLOW_PATHS=../storage/app/publicWtedy 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)(czylistd::fs::canonicalize). Jeślirealpathzawiedzie (cel nie istnieje), serwer odmawia uruchomienia. - Wpisy względne — łączone z kanonicznym
DOCUMENT_ROOT, a następnie poddawane działaniurealpath.
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:
- leży wewnątrz
DOCUMENT_ROOT, albo - jest dokładnie równa jednemu z wpisów listy dozwolonych (cele będące plikami), albo
- 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 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.
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.pharw nazwie, wskazujące na phar, którego nazwa je zawiera, jest uruchamiane jako phar.
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:
/ /etc /proc /sys /dev /var /home /tmp /root /usr /srvZabronione jako prefiks (wpis leży pod jednym z tych katalogów):
/etc /proc /sys /dev /tmp /root /usr/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
/procoraz 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/passwdi tak zostałoby odrzucone przez listę blokowania, ale bardziej ogólnie: umieszczenie na liście pojedynczego pliku/opt/shared/license.keynie daje niejawnie dostępu do plików sąsiednich - Walidacja w czasie routingu jest buforowana per żądany adres URL (jeden
realpathna 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ąstracena PHP 8.4):openicloseskryptu, ten odczyt lokalizacji oraz wywołaniafstat,fcntlilseekna deskryptorze. Dotyczy to również żądania obsłużonego przez OPcache, które przy wyłączonymopcache.validate_timestampsw 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.
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
- Zabronione ścieżki PHP — blokowanie wykonywania PHP pod określonymi wzorcami URI (glob); ortogonalne wobec polityki dowiązań symbolicznych
- Blokowanie ścieżek z kropką — odrzuca przechodzenie w stylu
.well-knownoraz wycieki plików ukrytych - Zaufane proxy — osobna granica zaufania dla nagłówków
X-Forwarded-* - Dokumentacja konfiguracji — wszystkie zmienne środowiskowe