Разрешённые пути для символьных ссылок

По умолчанию 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.

Конфигурация

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

Когда переменная не задана (по умолчанию), ни одна символьная ссылка не может выйти за пределы DOCUMENT_ROOT.

Пример для Laravel

bash
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.

Полученные канонические пути сохраняются как список разрешений. Дубликаты молча устраняются.

Во время запроса слой маршрутизации канонизирует разрешённый путь к файлу и проверяет, что он удовлетворяет одному из условий:

  1. находится внутри DOCUMENT_ROOT, либо
  2. точно совпадает с одной из записей списка разрешений (цели-файлы), либо
  3. начинается с одной из записей списка разрешений, за которой следует / (цели-каталоги).

Проверка при маршрутизации кеширует своё решение для каждого пути, поэтому файл, который в итоге читается, проверяется ещё раз.

`DOCUMENT_ROOT` за релизной символьной ссылкой

DOCUMENT_ROOT разрешается один раз, при старте, и статические файлы, читаемые с диска, и PHP-скрипты проверяются относительно этого расположения при каждом открытии. Поэтому корень, доступный через релизную символьную ссылку, например /app/current/public в раскладке в стиле Capistrano или Deployer, после переключения current на новый релиз отвечает 404 и для тех, и для других, пока сервер не перезапустится. Файлы, отдаваемые из кеша в памяти, продолжают отвечать теми байтами, которые в нём хранятся. Указывайте в DOCUMENT_ROOT путь, который не меняется от релиза к релизу, или перезапускайте сервер в рамках переключения.

Статические файлы

Для статических файлов проверка выполняется второй раз в момент отдачи — на действительно открытом файле и до того, как из него прочитан хотя бы один байт. Расположение берётся из открытого дескриптора (/proc/self/fd/N в Linux, F_GETPATH в macOS), поэтому символьная ссылка, подменённая после закешированной проверки маршрутизации, всё равно отклоняется. Там, где путь дескриптора недоступен, путь разрешается заново и должен указывать на тот же файл, который был открыт.

Note

Файл, отданный из кеша в памяти, не открывается заново. Подменённая ссылка продолжает возвращать байты, закешированные из её прежней, проверенной цели, пока запись не будет вытеснена или, при 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; опечатки и недопонимания иначе резко расширили бы поверхность атаки. Сервер отказывается запускаться, если любая запись разрешается в путь из блэклиста.

Запрещено как точное совпадение:

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

Запрещено как префикс (запись находится под одним из этих каталогов):

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

/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 — нужные страницы он прочитает сам.

prompt
Настрой `SYMLINK_ALLOW_PATHS` под символические ссылки на хранилище, от которых зависит моё приложение. Прочитай страницу `security/symlink-allow-paths` через MCP-сервер документации OxPHP. Найди символические ссылки под моим корнем документов — например, ссылку в духе `storage:link` — и их реальные цели, затем дай записи для списка разрешений и отметь любую цель, которая попадёт в чёрный список.

Смотрите также

Нашли ошибку? Сообщите →