Дозволені шляхи для символьних посилань
За замовчуванням 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` для storage-посилань, від яких залежить мій застосунок. Прочитай сторінку `security/symlink-allow-paths` через MCP-сервер документації OxPHP.
Знайди символьні посилання під моїм document root, як-от посилання в стилі `storage:link`, і їхні справжні цілі, а потім дай мені записи для списку дозволених і познач кожну ціль, яка потрапила б під чорний список.Див. також
- Заборона PHP за шляхами — блокує виконання PHP для певних URI-шаблонів; не залежить від політики символьних посилань
- Блокування шляхів із крапкою — відхиляє обхід у стилі
.well-knownі витоки dot-файлів - Довірені проксі — окрема межа довіри для заголовків
X-Forwarded-* - Довідник із налаштування — усі змінні середовища