Правила запрета путей (.oxphpdeny)
Файл .oxphpdeny в корне DOCUMENT_ROOT перечисляет пути запросов, которые сервер не должен отдавать напрямую. Он записывается в синтаксисе .gitignore с одним дополнением: правило с префиксом > не запрещает запрос, а передаёт его входному скрипту приложения.
# Never served — answered with PHP_DENY_FALLBACK (404 by default)
/vendor
/composer.*
*.sql
*.bak
# Not served directly — the front controller decides
> /storage/invoices/Он предназначен для файлов, которым не место в корне документов, но которые там часто оказываются: vendor/ и composer.json в legacy-приложении, которое отдаётся из корня проекта, дампы баз данных и резервные копии, артефакты сборки и source maps в каталоге public/ фреймворка. Правила действуют во всех четырёх режимах маршрутизации — и для статических файлов, и для PHP-скриптов. Они проверяются до любого обращения к диску, поэтому запрещённый путь получает один и тот же ответ независимо от того, существует файл или нет.
Файл читается один раз, при запуске: правки вступают в силу при следующем запуске. Без файла ничего не меняется. Сам файл никогда не отдаётся. Как и любой сегмент пути, начинающийся с . (кроме ведущего /.well-known/), он отклоняется ещё до маршрутизации блокировкой путей с точкой.
Синтаксис
Всё, что поддерживает .gitignore, поддерживает и .oxphpdeny:
- Пустые строки и строки, начинающиеся с
#, пропускаются. Метка порядка байтов UTF-8 (BOM) и окончания строк CRLF допускаются. - Пробелы в конце строки отбрасываются, если они не экранированы (
\). !снова включает то, что исключило предыдущее правило.\#и\!в начале строки означают буквальные#и!.- Завершающий
/означает, что правило совпадает только с каталогами. /в начале или в середине привязывает шаблон к корнюDOCUMENT_ROOT. Без него шаблон совпадает на любой глубине:vendorпокрывает и/vendor, и/lib/vendor.*и?не выходят за пределы одного сегмента пути;[abc],[a-z],[!0-9]и[^0-9]— классы символов. Как и в git,?и класс совпадают с одним байтом, а класс никогда не совпадает с/:[!-]— это любой байт имени, кроме-. Буква вне ASCII занимает два байта и больше, поэтому заменить её может только*: пишите/caf*.txt, а не/caf?.txt. Внутри класса не поддерживаются POSIX-классы, экранирование обратной косой чертой, символы вне ASCII,-сразу после диапазона и диапазон в обратном порядке, а инвертированный класс в запрещающем правиле или правиле>не может содержать ASCII-букву (см. Отличия от.gitignore).**/в начале,/**в конце и/**/в середине совпадают через каталоги;**в любом другом месте — это обычная*. Более длинная серия*считается**, как в git:***/secret.txtпокрывает/secret.txtна любой глубине.- Побеждает последнее совпавшее правило, и ничего внутри исключённого каталога нельзя включить обратно: если исключён
private/, правило!private/ok.txtни на что не влияет. Чтобы исключить содержимое каталога, но оставить часть файлов, исключите/private/*и включите обратно!/private/ok.txt.
Правила >: передача запроса приложению
> /storage/invoices/
> *.mapЗапрос, совпавший с правилом >, не отдаётся с диска. Он уходит во входной скрипт с нетронутым REQUEST_URI — точно так же, как запрос к отсутствующему файлу, — и приложение может проверить сессию и само отдать файл потоком. Входной скрипт — это фронт-контроллер (ENTRY_FILE=index.php) или воркер. Между > и шаблоном допускаются пробелы и табуляции; \> означает буквальный >. Правило > нельзя инвертировать (!>x — ошибка), и > без шаблона — тоже ошибка.
Запрещающее правило продолжает действовать внутри каталога >: при > /storage/invoices/ и *.sql путь /storage/invoices/dump.sql запрещён, в каком бы порядке ни шли строки. Правило !, снимающее запрет, оставляет файл каталогу >, как в git: добавьте !/storage/invoices/dump.sql после *.sql, и этот запрос получит входной скрипт. Внутри каталога >, как и внутри любого исключённого каталога, ! ничего не включает обратно, поэтому файл по-прежнему не отдаётся с диска.
Правилам > нужен входной скрипт. В традиционном режиме и в режиме SPA они прерывают запуск.
Отличия от .gitignore
- Некорректная строка прерывает запуск с указанием пути к файлу и номера строки; так же прерывает его файл не в кодировке UTF-8. git молча пропускает такие строки.
{a,b}понимается буквально. Фигурные скобки совпадают сами с собой, как в git.- Класс, содержащий
/, прерывает запуск — как напрямую ([-/]), так и через диапазон, проходящий через него, например[+-0]. git такой класс принимает и никогда не сопоставляет с ним/; пишите разделитель вне класса. - POSIX-класс, обратная косая черта внутри класса или
-сразу после диапазона прерывают запуск:[[:digit:]],[\]],[a-b-c]. git читает каждый из них по-своему. Пишите вместо этого диапазон ([0-9]), ставьте]первым, чтобы сопоставить его, а буквальный-— первым или последним ([]-],[-a-c]). - Диапазон в обратном порядке прерывает запуск, например
[z-a]или[a--]. git читает его как один первый символ, так чтоx[z-a]покрывает толькоxz; пишите этот символ или ставьте меньшую границу первой. - Класс с символом вне ASCII прерывает запуск, например
[äÄ]. git сопоставляет класс с одним байтом, как и сервер, а такой символ занимает два байта и больше:/[äÄ]dminне покрыл бы ни/ädmin, ни/Ädmin, а/[äÄ]*покрыл бы/öffnungszeiten, который начинается с того же байта. Ставьте на его место*. - Экранированный
/прерывает запуск (**\/secret.txt,\/vendor). git читает**\/secret.txtкакsecret.txtвнутри хотя бы одного каталога (но не сам/secret.txt), чего сервер не сделал бы, а\/vendorне совпадает ни с каким путём. Разделителю экранирование никогда не нужно: пишите/. - Строка, которая покрывает не то, что в ней написано, прерывает запуск: строка, которая начинается с пробела или табуляции, оканчивается неэкранированной табуляцией, где-либо содержит
//или содержит NUL-байт. git принимает каждую из них. Пробельные символы на любом конце становятся частью имени:> /storage/invoices/с отступом — это запрещающее правило для пути, начинающегося с пробела, а не правило>. Экранируйте такой символ с помощью\, когда имя действительно начинается или заканчивается им.//vendorиvendor//не совпадают ни с каким путём. git обрывает шаблон на NUL, поэтомуvendor, NUL и дальнейший текст покрывают в git/vendor, тогда как сервер прочитал бы всю строку и покрыл бы только имя, содержащее этот NUL. В файле, сохранённом в UTF-16, после каждого ASCII-символа стоит NUL: сохраняйте файл в UTF-8. - Строка из пробелов и табуляций считается пустой. git читает строку с табуляцией как шаблон для имени, состоящего из этих пробельных символов.
- Запрещающие правила и правила
>не различают регистр ASCII-букв, а правила!различают. Сервер выполняет/uploads/shell.PHPкак PHP-скрипт на любой файловой системе, а нечувствительная к регистру (macOS, Windows) отдаёт/VENDOR/autoload.phpизvendor/, так что правило, совпадающее с одним написанием, пропустило бы остальные:*.phpпокрываетshell.PHP, аvendor/покрывает/Vendor/. Правило!включает обратно только то написание, которое в нём указано (при*и!/index.phpпуть/INDEX.PHPостаётся запрещённым), поэтому игнорирование регистра никогда не позволяет серверу отдать с диска путь, который точное сопоставление от него закрыло бы. Чтобы так и оставалось, инвертированный класс в запрещающем правиле или правиле>не может содержать ASCII-букву, а класс, который её содержит, прерывает запуск: без учёта регистра[!i]исключал бы иI, и при/[!i]*.phpвыполнился бы/Info.php. Сопоставляйте в этом месте любой символ и включайте исключение обратно — правило!затем совпадёт с ним точно:/*.phpи!/i*.php. git сопоставляет с учётом регистра, если не заданcore.ignorecase. - Несколько букв вне ASCII читаются как ASCII. Так их читает нечувствительная к регистру файловая система (macOS), которая отдаёт
license.txtв ответ на/licenſe.txt:ſ(длинная s) какs,ßиẞкакss, знак кельвина (U+212A) какk, лигатурыfffiflffifflſtstкак буквы, которые они объединяют, а греческий вопросительный знак (U+037E) и варию (U+1FEF) как;и`. Запрещающие правила и правила>читают их так же — и в запросе, и в правиле:/license.txtпокрывает/licenſe.txt, а/straße/покрывает/strasse/. Правило по-прежнему покрывает то, что покрывало в написанном виде, а правила!читают эти буквы только так, как они написаны, поэтому запрет может только расшириться. - Прочие буквы вне ASCII совпадают только так, как они написаны; то же относится к каждой форме Unicode для имени. Правило для
/ädminне покрывает/Ädmin, а правило дляcafé.txt, записанное с предкомпонованнойé, не покрываетcafeс последующим комбинирующим диакритическим знаком (U+0301). Большинство файловых систем Linux хранят каждое написание как отдельный файл, поэтому это важно на macOS, которая считает обе пары одним файлом, в том числе через bind-mount из macOS в Docker Desktop. Там запрос с другим написанием отдаётся с диска. Ставьте*вместо таких букв (/caf*.txt), поскольку?и класс совпадают с одним байтом, или держите такие файлы внеDOCUMENT_ROOT. - Не более 256 правил, каждое не длиннее 128 байт. Правило, выходящее за любой из этих пределов, прерывает запуск с указанием номера своей строки (см. Ограничения).
- Один файл, в корне
DOCUMENT_ROOT, читается при запуске. Файлы в подкаталогах не читаются. Это должен быть обычный файл размером не более 1 MiB; символьная ссылка на такой файл допустима. FIFO или устройство прерывают запуск без чтения, а файл большего размера — после чтения его первого MiB.
Что сопоставляется
Правила сопоставляются с путём запроса после процентного декодирования и нормализации. Пустые сегменты отбрасываются, поэтому //vendor/x, /vend%6Fr/x и /vendor%2Fx — это всё /vendor/x. Сегмент, начинающийся с . (., .., .git), до правил не доходит: запрос сначала отклоняется простым 404 — без PHP_DENY_FALLBACK и без учёта в метрике. Исключение — ведущий /.well-known/ (RFC 8615), который блокировка путей с точкой пропускает, и правила видят его как любой другой путь.
Запрос считается запросом к каталогу, когда его декодированный путь оканчивается на / (/admin/, /admin%2F, /admin//). Правило с завершающим слешем совпадает только с такими запросами и со всем, что находится ниже них:
| Правило | /admin |
/admin/ |
/admin/x |
/admin/x/y |
|---|---|---|---|---|
admin/ |
— | запрещён | запрещён | запрещён |
admin или /admin |
запрещён | запрещён | запрещён | запрещён |
admin/*, admin/x |
— | — | запрещён | запрещён |
Голый / никогда не сопоставляется, поэтому файл, построенный как белый список, по-прежнему отдаёт корень сайта:
*
!/index.php
!/assets/
!/assets/**
!/.well-known/
!/.well-known/**Сохраните строки с /.well-known/, если сайт продлевает TLS-сертификат через ACME HTTP-01. Без них /.well-known/acme-challenge/<token> будет запрещён.
Оцениваются также два других прочтения запроса, и запрет по любому из них остаётся в силе:
- Запрос к каталогу оценивается также как файл, который он называет. Маршрутизатор отбрасывает завершающий слеш и может отдать
/secret.txt/как файлsecret.txt. - В традиционном режиме сегмент
.php, за которым следует продолжение пути, оценивается как скрипт, который он называет./secret.php/x.jsвыполняетsecret.phpс PATH_INFO/x.js, поэтому такой запрос запрещён везде, где запрещён/secret.php.
В обоих случаях !*/ — принятый в gitignore способ включить обратно все каталоги — не может вернуть запрещённый файл. В белом списке включайте каталог обратно без слеша (!/docs), чтобы сам /docs/ оставался доступен. Никакой другой файл, в который маршрутизатор разрешает запрос, не оценивается, как показывает следующий раздел.
Путь запроса, а не файл
Правила видят путь, который запросил клиент, а не файл, в который его разрешает маршрутизатор. Запрос, который разрешается в другой файл, оценивается по собственному пути:
/adminс правиломadmin/: в традиционном режиме маршрутизатор отвечает на/adminскриптомadmin/index.php, а/adminне является запросом к каталогу./uploads/с правиломuploads/*.php: выполняется индекс каталогаuploads/index.php, потому что путь запроса —/uploads/./x.php/y.png(PATH_INFO) выполняетx.php, хотя путь оканчивается на.png. Скрипт назван в пути, поэтому традиционный режим оценивает запрос ещё и как/x.php, как описано выше.
Чтобы закрыть каталог, пишите его без завершающего слеша (/admin). Чтобы запретить выполнение .php в каталогах, доступных для записи, используйте также PHP_DENY_PATHS: он проверяет ещё и скрипт, в который разрешается запрос.
Режимы маршрутизации
| Режим маршрутизации | Запрещающие правила | Правила > |
|---|---|---|
Традиционный (без ENTRY_FILE) |
Да | Ошибка при запуске: входного скрипта нет |
SPA (ENTRY_FILE=index.html) |
Да | Ошибка при запуске, поскольку index.html не может принять запрос |
Framework (ENTRY_FILE=index.php) |
Да | Фронт-контроллер |
Воркер (WORKER_MODE_ENABLED=true) |
Да, с фолбэком только в виде кода статуса | Входной скрипт воркера |
Фолбэк
Запрещающее правило отвечает согласно PHP_DENY_FALLBACK — той же настройке, которую использует PHP_DENY_PATHS: HTTP-статусом (по умолчанию 404; сочетается с ERROR_PAGES_DIR) или PHP-скриптом внутри DOCUMENT_ROOT. Скрипт получает OXPHP_DENIED_PATH (путь запроса с ведущим /) и OXPHP_DENIED_PATTERN (правило в том виде, в каком оно записано в файле, например /composer.*).
Скрипт может лежать по пути, который запрещает сам файл. /_security/ скрывает его от прямых запросов, а на прямой запрос к нему отвечает сам скрипт — как на запрет. PHP_DENY_FALLBACK читается только тогда, когда в файле есть хотя бы одно запрещающее правило (или когда действует PHP_DENY_PATHS — в традиционном режиме и в режиме SPA).
В режиме воркеров фолбэк должен быть кодом статуса: поток воркера не выполняет ничего, кроме входного скрипта воркера.
Когда с запросом совпадают и .oxphpdeny, и PHP_DENY_PATHS, отвечает .oxphpdeny.
Наблюдаемость
- Метрика
oxphp_path_deny_totalсчитает запросы, запрещённые правилом. Правила>— это маршрутизация, а не запреты, и они не учитываются.oxphp_php_deny_totalпо-прежнему считает толькоPHP_DENY_PATHS; запрос, запрещённый обоими механизмами, учитывается один раз — вoxphp_path_deny_total. - На
/configзаписьdeny_fileпоказывает число правил по действиям и вид фолбэка, но никогда не сами правила (см. Внутренний сервер). - Лог запуска сообщает
loaded 5 rules (3 deny, 1 entry, 1 allow) from /var/www/html/public/.oxphpdeny. ПриLOG_LEVEL=debugдля каждого правила выводится строкаoxphpdeny rule, которая показывает, как оно было прочитано: номер строки, действие, шаблон в написанном виде и glob, в который он скомпилирован. - Для каждого запроса каждый запрет логируется сообщением
request denied by .oxphpdenyна уровнеdebugс путём, правилом и его строкой. Запрос, отклонённый из-за глубины или длины, логируетrequest refused: path too deep or too long to match against .oxphpdenyна уровнеdebugс числом сегментов и длиной; в метрике он не учитывается.
Ошибки при запуске
| Условие | Результат |
|---|---|
Нет .oxphpdeny |
Функция выключена; ничего не меняется |
| Файл нечитаем или не в UTF-8, либо это символьная ссылка, цель которой отсутствует | Ошибка при запуске |
| Некорректная строка | Ошибка при запуске с <path>: line N: <message> |
| Больше 256 правил или шаблон длиннее 128 байт | Ошибка при запуске с номером строки |
Правило > в традиционном режиме или в режиме SPA |
Ошибка при запуске |
Режим воркеров, запрещающее правило и PHP_DENY_FALLBACK в виде скрипта |
Ошибка при запуске |
Недопустимый PHP_DENY_FALLBACK и хотя бы одно запрещающее правило |
Ошибка при запуске |
Пустой файл, только комментарии или только правила ! |
Запрещать нечего: маршрутизация не меняется, включая ограничение глубины; /config показывает файл как загруженный, с числом правил |
oxphp config --check сообщает об ошибках файла ещё до развёртывания (config: INVALID). Проверки фолбэка выполняются при запуске, когда сервер строит маршрутизацию.
Ограничения
- Глубина и длина пути ограничены. Если есть хотя бы одно запрещающее правило или правило
>, путь запроса глубже 64 сегментов или такой, у которого число сегментов, умноженное на длину, превышает 128 KiB, получает ответ404без сопоставления. Сопоставление заново проверяет каждый родительский каталог и сканирует путь каждого из них, поэтому иначе его стоимость росла бы и с глубиной, и с длиной. Путь длиной до 2 KiB отклоняется только из-за глубины; дальше бюджет исчерпывается уже 16 сегментами при 8 KiB или 3 сегментами примерно при 43 KiB. - Файл содержит не более 256 правил, а каждый шаблон — не более 128 байт без учёта ведущего
!или>. Путь запроса сопоставляется с правилами по одному, а также каждый его родительский каталог, поэтому эти лимиты и лимит выше ограничивают стоимость запроса. Типичный файл обходится значительно меньше чем в миллисекунду на запрос. Файл на обоих пределах, специально построенный так, чтобы работать медленно (256 длинных правил, начинающихся с/x/**/*, против путей, подобранных так, чтобы почти совпадать с ними), обходится примерно в 40 ms на запрос и держит около 50 MB состояния сопоставления на каждый рабочий поток Tokio. - О чувствительности к регистру см. Отличия от
.gitignore. - Перезагрузки нет. Чтобы применить правку, перезапустите сервер.
Примеры
Legacy-приложение, которое отдаётся из корня проекта:
/vendor
/composer.*
/config
*.sql
*.sql.gz
*.bak
*.log/readme.html
/license.txt
/wp-config-sample.php
/wp-content/debug.logВ режиме Framework фронт-контроллер получает /storage/invoices/2026-07.pdf, проверяет сессию и отдаёт файл потоком:
> /storage/invoices/Промпты для вашего агента
Эти промпты рассчитаны на агента с подключённым MCP-сервером документации OxPHP — нужные страницы он прочитает сам.
Напиши файл `.oxphpdeny` для приложения в этом репозитории. Прочитай страницу `security/oxphpdeny` через MCP-сервер документации OxPHP.
Выясни, какой каталог служит `DOCUMENT_ROOT` и какой режим маршрутизации использует развёртывание, затем найди, что в этом каталоге никогда не должно отдаваться, — `vendor/`, `composer.*`, файлы конфигурации, дампы баз данных, резервные копии, логи, source maps, — и какие из этих путей приложение отдаёт само, так что вместо запрета им нужно правило `>`. Дай мне файл с комментарием к каждому правилу и перечисли то, что он не может покрыть, потому что правила сопоставляются с путём запроса, а не с файлом, в который он разрешается.Перенеси запрещающие правила из конфигурации моего старого веб-сервера в `.oxphpdeny`. Прочитай страницу `security/oxphpdeny` через MCP-сервер документации OxPHP.
Найди в этом репозитории блоки `location` в nginx с `deny all` или `return 403`/`404` и правила `.htaccess`, запрещающие доступ, и переведи каждое из них в строку `.oxphpdeny`. Скажи мне, какие из них уже покрывает блокировка путей с точкой, а какие файл выразить не может.Проверь файл `.oxphpdeny` в этом репозитории на пробелы в защите. Прочитай страницу `security/oxphpdeny` через MCP-сервер документации OxPHP.
Сверь каждое правило с тем, как развёрнуто это приложение: каталоги, записанные с завершающим слешем, до которых всё равно доходит индекс каталога; имена с буквами вне ASCII, которые macOS отдаёт под другим написанием; каталоги для записи, которым нужен ещё и `PHP_DENY_PATHS`; белый список, который заблокировал бы `/.well-known/acme-challenge/`. Дай мне исправленный файл и скажи, что закрывает каждое изменение.См. также
- Дени-лист выполнения PHP —
PHP_DENY_PATHSиPHP_DENY_FALLBACK - Блокировка путей с точкой — скрытые файлы и каталоги, которые отклоняются ещё до этих правил
- Маршрутизация — режимы маршрутизации и безопасность путей
- Метрики —
oxphp_path_deny_total