Разрешённые пути для символьных ссылок
По умолчанию OxPHP отклоняет любой запрос, который разрешается в путь за пределами канонического DOCUMENT_ROOT. Символьная ссылка внутри DOCUMENT_ROOT, указывающая на внешний каталог, возвращает 404, а разрешение пути логирует Blocked request: resolved path escapes document root.
Это правильное поведение по умолчанию: оно останавливает обход каталогов и случайное раскрытие конфигурационных файлов или секретов, лежащих уровнем выше. Но оно же блокирует легитимные паттерны, которые фреймворки используют уже много лет: php artisan storage:link в Laravel, бандлы ресурсов Symfony, общие тома для загрузок, смонтированные в несколько контейнеров.
SYMLINK_ALLOW_PATHS — это явное согласие на исключение: вы перечисляете пути в файловой системе, в которые разрешено разрешаться символьным ссылкам под DOCUMENT_ROOT. Всё, чего нет в списке, сохраняет строгое поведение с 404.
Конфигурация
# 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Когда переменная не задана (по умолчанию), ни одна символьная ссылка не может выйти за пределы DOCUMENT_ROOT.
Пример для Laravel
DOCUMENT_ROOT=/app/public
SYMLINK_ALLOW_PATHS=../storage/app/publicТогда php artisan storage:link создаёт public/storage -> ../storage/app/public внутри проекта. URL-адреса вида /storage/<file> разрешаются через символьную ссылку в /app/storage/app/public/<file>, который канонизируется в путь, авторизованный списком разрешений. Изменений в коде приложения не требуется.
Как это работает
При старте каждая запись разрешается:
- Абсолютные записи — проверяются по блэклисту (см. ниже), затем пропускаются через
realpath(3)(то естьstd::fs::canonicalize). Еслиrealpathзавершается ошибкой (цель не существует), сервер отказывается запускаться. - Относительные записи — соединяются с каноническим
DOCUMENT_ROOT, затем обрабатываютсяrealpath.
Полученные канонические пути сохраняются как список разрешений. Дубликаты молча устраняются.
Во время запроса слой маршрутизации канонизирует разрешённый путь к файлу и проверяет, что он удовлетворяет одному из условий:
- находится внутри
DOCUMENT_ROOT, либо - точно совпадает с одной из записей списка разрешений (цели-файлы), либо
- начинается с одной из записей списка разрешений, за которой следует
/(цели-каталоги).
Проверка при маршрутизации кеширует своё решение для каждого пути, поэтому файл, который в итоге читается, проверяется ещё раз.
DOCUMENT_ROOT разрешается один раз, при старте, и статические файлы, читаемые с диска, и PHP-скрипты проверяются относительно этого расположения при каждом открытии. Поэтому корень, доступный через релизную символьную ссылку, например /app/current/public в раскладке в стиле Capistrano или Deployer, после переключения current на новый релиз отвечает 404 и для тех, и для других, пока сервер не перезапустится. Файлы, отдаваемые из кеша в памяти, продолжают отвечать теми байтами, которые в нём хранятся. Указывайте в DOCUMENT_ROOT путь, который не меняется от релиза к релизу, или перезапускайте сервер в рамках переключения.
Статические файлы
Для статических файлов проверка выполняется второй раз в момент отдачи — на действительно открытом файле и до того, как из него прочитан хотя бы один байт. Расположение берётся из открытого дескриптора (/proc/self/fd/N в Linux, F_GETPATH в macOS), поэтому символьная ссылка, подменённая после закешированной проверки маршрутизации, всё равно отклоняется. Там, где путь дескриптора недоступен, путь разрешается заново и должен указывать на тот же файл, который был открыт.
Файл, отданный из кеша в памяти, не открывается заново. Подменённая ссылка продолжает возвращать байты, закешированные из её прежней, проверенной цели, пока запись не будет вытеснена или, при STATIC_REVALIDATE=on, пока ревалидация не заметит изменение.
PHP-скрипты
PHP-скрипты обрабатываются так же во всех режимах, кроме режима воркеров, чей входной файл — путь, заданный администратором. Воркер сам открывает скрипт, так же проверяет расположение открытого дескриптора и передаёт движку этот открытый файл, а не путь, поэтому открытый файл — это и есть проверенный файл. Есть одно исключение: скрипт, чьё разрешённое имя содержит .phar и который является zip-, tar- или сжатым phar-архивом, повторно открывается по имени расширением phar, и это повторное открытие проверкой не покрывается.
| Скрипт в момент отдачи | Результат |
|---|---|
| Не обычный файл (каталог, FIFO) или существует, но не может быть прочитан | 500, в лог пишется Cannot open script. Собственные предупреждение PHP о неудачном открытии и фатальная ошибка не выдаются. |
| Удалён после маршрутизации | 404, в том числе для скрипта, который хранится в OPcache и при выключенном opcache.validate_timestamps иначе выполнился бы из кеша без открытия файла |
| Ссылка перенаправлена после маршрутизации на скрипт или на текстовый файл, который движок вывел бы как есть | 404, в лог пишется Blocked request: opened script escapes document root |
Ссылка перенаправлена на другой скрипт внутри DOCUMENT_ROOT или на цель из списка разрешений |
Выполняется этот скрипт |
__FILE__, __DIR__ и $_SERVER['SCRIPT_FILENAME'] такие же, как без проверки. Поскольку движку передаётся разрешённое расположение файла, из этого следуют три вещи:
- OPcache хранит скрипт, к которому обратились через ссылку, под файлом, в который разрешается ссылка.
- Рабочий каталог скрипта — это каталог этого файла. Для ссылки на файл в другом каталоге это не тот каталог, в котором лежит ссылка.
- Расширение phar по этому разрешённому имени решает, является ли скрипт phar-архивом. Ссылка с именем
app.phar.php, указывающая на phar на основе zip, в собственном имени которого нет.phar, компилируется как обычный файл, а ссылка без.pharв имени, указывающая на phar, в имени которого.pharесть, выполняется как phar.
Проверка охватывает скрипт, на который был направлен запрос. Файлы, которые этот скрипт подключает через include или require или открывает по имени, а также auto_prepend_file разрешаются PHP как обычно. Там, где скрипты или их каталоги доступны для записи коду, которому вы не доверяете, добавьте ещё и open_basedir.
open_basedir действует и на сам скрипт, на который направлен запрос: скрипт за его пределами отклоняется так же, как PHP отклоняет любой скрипт, который не может открыть, — со статусом 500 и обычными предупреждениями open_basedir restriction in effect. Проверка выполняется, когда PHP компилирует скрипт, в той же точке, что и для скрипта, который PHP открывает сам: после выполнения auto_prepend_file, с каталогом скрипта в качестве рабочего каталога. Поэтому относительная запись вроде open_basedir=. означает каталог скрипта, а open_basedir, который файл из auto_prepend_file сужает через ini_set(), действует на скрипт, перед которым этот файл выполняется, — в том числе на скрипт, уже хранящийся в OPcache, который PHP выполнил бы без проверки.
Блэклист
Небольшой набор путей никогда не может фигурировать в SYMLINK_ALLOW_PATHS; опечатки и недопонимания иначе резко расширили бы поверхность атаки. Сервер отказывается запускаться, если любая запись разрешается в путь из блэклиста.
Запрещено как точное совпадение:
/ /etc /proc /sys /dev /var /home /tmp /root /usr /srvЗапрещено как префикс (запись находится под одним из этих каталогов):
/etc /proc /sys /dev /tmp /root /usr/var, /home и /srv запрещены только как точное совпадение: голый /srv отклоняется, но /srv/myapp/storage разрешён, точно так же как разрешены /var/www/storage и /home/<any>/.... Записи проверяются дважды: один раз против сырого пути, предоставленного администратором (чтобы путь в стиле macOS /etc -> /private/etc не смог протащить заблэкличенный путь через realpath), и один раз против канонической формы (эшелонированная защита от выхода через цель символьной ссылки).
Сам блэклист зашит в код; переменной окружения для его расширения нет. По умолчанию используется консервативный минимум, отлавливающий ошибки уровня опечаток; администраторы, которым нужны более строгие политики, должны выстраивать их снаружи (права доступа в файловой системе, ограничения монтирования контейнеров, профили AppArmor/SELinux).
Режимы отказа
| Ошибка конфигурации | Результат |
|---|---|
| Цель записи не существует на диске | Сервер отказывается запускаться, ошибка называет запись и canonicalize |
| Запись совпадает с блэклистом (сырая или каноническая) | Сервер отказывается запускаться, ошибка называет запись и правило блэклиста |
| Пустая переменная окружения или только из пробелов | Трактуется как незаданная — строгое поведение по умолчанию |
| Дублирующиеся записи | Молча устраняются после канонизации |
| При старте символьной ссылки ещё нет | Список разрешений регистрируется, но остаётся неактивным, пока не появится символьная ссылка; ни одна стартовая проверка не требует наличия символьной ссылки |
Замечания по безопасности
- Список разрешений работает по принципу явного согласия — безопасное поведение по умолчанию «никаких выходов» сохраняется, пока переменная не задана
- Записи канонизируются при старте, поэтому
..и промежуточные символьные ссылки в пути записи сворачиваются до сохранения - Статические файлы и PHP-скрипты проверяются на открытом файле, поэтому в Linux со смонтированным
/proc, а также в macOS подмена символьной ссылки после того, как она уже была отдана один раз, не позволит последующему ответу прийти из-за пределов разрешённых расположений или выполнить скрипт оттуда. Запасной механизм, применяемый в остальных случаях, даёт более узкую гарантию, как описано выше - Цели-файлы совпадают точно; цели-каталоги совпадают по префиксу каталога. Указание файла
/etc/passwdвсё равно было бы отклонено блэклистом, но в более общем случае: указание одного файла/opt/shared/license.keyне даёт неявного доступа к соседним файлам - Проверка во время маршрутизации кешируется для каждого запрошенного URL (один
realpathна уникальный URL до вытеснения из кеша). Проверка в момент отдачи добавляет поиск расположения открытого дескриптора для каждого статического файла, читаемого с диска, и ничего не добавляет для файла, отдаваемого из кеша в памяти. Для PHP-скрипта вне режима воркеров она добавляет к каждому запросу около восьми системных вызовов (подсчитано с помощьюstraceна PHP 8.4):openиcloseскрипта, тот самый поиск, а также вызовыfstat,fcntlиlseekна дескрипторе. Это касается и запроса, на который отвечает OPcache и который иначе при выключенномopcache.validate_timestampsвообще не обращался бы к файлу. На скрипте, который выводит одно слово, это составило около 3% пропускной способности; реальное приложение тратит на это пропорционально меньшую долю своего запроса
Промпты для вашего агента
Эти промпты рассчитаны на агента с подключённым MCP-сервером документации OxPHP — нужные страницы он прочитает сам.
Настрой `SYMLINK_ALLOW_PATHS` под символические ссылки на хранилище, от которых зависит моё приложение. Прочитай страницу `security/symlink-allow-paths` через MCP-сервер документации OxPHP.
Найди символические ссылки под моим корнем документов — например, ссылку в духе `storage:link` — и их реальные цели, затем дай записи для списка разрешений и отметь любую цель, которая попадёт в чёрный список.Смотрите также
- Запрет путей для PHP — блокировка выполнения PHP по определённым URI-глобам; ортогонально политике символьных ссылок
- Блокировка путей с точкой — отклоняет обход в стиле
.well-knownи утечки dot-файлов - Доверенные прокси — отдельная граница доверия для заголовков
X-Forwarded-* - Справочник по конфигурации — все переменные окружения