Правила запрета путей (.oxphpdeny)

Файл .oxphpdeny в корне DOCUMENT_ROOT перечисляет пути запросов, которые сервер не должен отдавать напрямую. Он записывается в синтаксисе .gitignore с одним дополнением: правило с префиксом > не запрещает запрос, а передаёт его входному скрипту приложения.

.oxphpdeny
# 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-скриптов. Они проверяются до любого обращения к диску, поэтому запрещённый путь получает один и тот же ответ независимо от того, существует файл или нет.

Note

Файл читается один раз, при запуске: правки вступают в силу при следующем запуске. Без файла ничего не меняется. Сам файл никогда не отдаётся. Как и любой сегмент пути, начинающийся с . (кроме ведущего /.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.

Правила >: передача запроса приложению

.oxphpdeny
> /storage/invoices/ > *.map

Запрос, совпавший с правилом >, не отдаётся с диска. Он уходит во входной скрипт с нетронутым REQUEST_URI — точно так же, как запрос к отсутствующему файлу, — и приложение может проверить сессию и само отдать файл потоком. Входной скрипт — это фронт-контроллер (ENTRY_FILE=index.php) или воркер. Между > и шаблоном допускаются пробелы и табуляции; \> означает буквальный >. Правило > нельзя инвертировать (!>x — ошибка), и > без шаблона — тоже ошибка.

Запрещающее правило продолжает действовать внутри каталога >: при > /storage/invoices/ и *.sql путь /storage/invoices/dump.sql запрещён, в каком бы порядке ни шли строки. Правило !, снимающее запрет, оставляет файл каталогу >, как в git: добавьте !/storage/invoices/dump.sql после *.sql, и этот запрос получит входной скрипт. Внутри каталога >, как и внутри любого исключённого каталога, ! ничего не включает обратно, поэтому файл по-прежнему не отдаётся с диска.

Warning

Правилам > нужен входной скрипт. В традиционном режиме и в режиме 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, лигатуры ff fi fl ffi ffl ſt st как буквы, которые они объединяют, а греческий вопросительный знак (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 — — запрещён запрещён

Голый / никогда не сопоставляется, поэтому файл, построенный как белый список, по-прежнему отдаёт корень сайта:

.oxphpdeny
* !/index.php !/assets/ !/assets/** !/.well-known/ !/.well-known/**
ACME HTTP-01

Сохраните строки с /.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, как описано выше.
Tip

Чтобы закрыть каталог, пишите его без завершающего слеша (/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).

Note

В режиме воркеров фолбэк должен быть кодом статуса: поток воркера не выполняет ничего, кроме входного скрипта воркера.

Когда с запросом совпадают и .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-приложение, которое отдаётся из корня проекта:

.oxphpdeny
/vendor /composer.* /config *.sql *.sql.gz *.bak *.log

Промпты для вашего агента

Эти промпты рассчитаны на агента с подключённым MCP-сервером документации OxPHP — нужные страницы он прочитает сам.

prompt
Напиши файл `.oxphpdeny` для приложения в этом репозитории. Прочитай страницу `security/oxphpdeny` через MCP-сервер документации OxPHP. Выясни, какой каталог служит `DOCUMENT_ROOT` и какой режим маршрутизации использует развёртывание, затем найди, что в этом каталоге никогда не должно отдаваться, — `vendor/`, `composer.*`, файлы конфигурации, дампы баз данных, резервные копии, логи, source maps, — и какие из этих путей приложение отдаёт само, так что вместо запрета им нужно правило `>`. Дай мне файл с комментарием к каждому правилу и перечисли то, что он не может покрыть, потому что правила сопоставляются с путём запроса, а не с файлом, в который он разрешается.
prompt
Перенеси запрещающие правила из конфигурации моего старого веб-сервера в `.oxphpdeny`. Прочитай страницу `security/oxphpdeny` через MCP-сервер документации OxPHP. Найди в этом репозитории блоки `location` в nginx с `deny all` или `return 403`/`404` и правила `.htaccess`, запрещающие доступ, и переведи каждое из них в строку `.oxphpdeny`. Скажи мне, какие из них уже покрывает блокировка путей с точкой, а какие файл выразить не может.
prompt
Проверь файл `.oxphpdeny` в этом репозитории на пробелы в защите. Прочитай страницу `security/oxphpdeny` через MCP-сервер документации OxPHP. Сверь каждое правило с тем, как развёрнуто это приложение: каталоги, записанные с завершающим слешем, до которых всё равно доходит индекс каталога; имена с буквами вне ASCII, которые macOS отдаёт под другим написанием; каталоги для записи, которым нужен ещё и `PHP_DENY_PATHS`; белый список, который заблокировал бы `/.well-known/acme-challenge/`. Дай мне исправленный файл и скажи, что закрывает каждое изменение.

См. также

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