路径拒绝规则(.oxphpdeny)
位于 DOCUMENT_ROOT 顶层的 .oxphpdeny 文件列出服务器不得直接提供的请求路径。它采用 .gitignore 语法,只多了一项扩展:以 > 开头的规则不会拒绝请求,而是把请求交给应用的入口脚本。
# 永不提供——以 PHP_DENY_FALLBACK 响应(默认 404)
/vendor
/composer.*
*.sql
*.bak
# 不直接提供——由前端控制器决定
> /storage/invoices/它针对的是那些本不该放在文档根目录里、却常常出现在那里的文件:从项目根目录提供服务的遗留应用中的 vendor/ 和 composer.json,数据库转储和备份,以及框架 public/ 目录下的构建产物和 source map。这些规则在全部四种路由模式下都适用,对静态文件和 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 一致:在 *.sql 之后加上 !/storage/invoices/dump.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/中的文件响应/VENDOR/autoload.php,因此只匹配一种拼写的规则会放过其他拼写:*.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)就是这样读取它们的,会对
/licenſe.txt提供license.txt:ſ(长 s)读作s,ß和ẞ读作ss,开尔文符号(U+212A)读作k,连字fffiflffifflſtst读作它们所连接的字母,希腊问号(U+037E)和希腊抑音符 varia(U+1FEF)读作;和`。拒绝规则和>规则也会这样读取它们,无论在请求中还是在规则中:/license.txt覆盖/licenſe.txt,/straße/覆盖/strasse/。规则照样覆盖它按字面所覆盖的内容,而!规则只按字面读取这些字母,因此拒绝范围只会扩大。 - 其他 ASCII 以外的字母只按字面匹配,名称的每种 Unicode 形式也是如此。 针对
/ädmin的规则不会覆盖/Ädmin,用预组合的é写成的café.txt规则也不会覆盖cafe后跟组合重音符(U+0301)的写法。大多数 Linux 文件系统会把每种拼写保留为独立的文件,所以这在 macOS 上才有影响:macOS 会把上述两对都视为同一个文件,通过绑定挂载从 macOS 挂进 Docker Desktop 时也是如此。在那里,对另一种拼写的请求会从磁盘得到提供。请在这类字母的位置改用*(/caf*.txt),因为?和字符类只匹配单个字节;或者把这些文件放在DOCUMENT_ROOT之外。 - 最多 256 条规则,每条最多 128 字节。 超出任一限制的规则会中止启动,并给出其行号(参见 限制)。
- 只有一个文件,位于
DOCUMENT_ROOT顶层,在启动时读取。 子目录中的文件不会被读取。它必须是不超过 1 MiB 的普通文件;指向这样一个文件的符号链接也可以。FIFO 或设备文件会在未被读取的情况下中止启动,更大的文件则在读完前 1 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/**如果站点通过 ACME HTTP-01 续期 TLS 证书,请保留 /.well-known/ 这几行。没有它们,/.well-known/acme-challenge/<token> 会被拒绝。
请求还会按另外两种解读接受判定,其中任一种被拒绝,拒绝就成立:
- 目录请求也会按它所指名的文件接受判定。 路由器会去掉末尾斜杠,可能把
/secret.txt/当作文件secret.txt提供。 - 在传统模式下,后面还跟着路径的
.php路径段会按它所指名的脚本接受判定。/secret.php/x.js会以 PATH_INFO/x.js运行secret.php,因此凡是/secret.php被拒绝的地方,它也会被拒绝。
无论哪种情况,!*/(gitignore 中重新包含所有目录的写法)都无法让被拒绝的文件恢复。在允许列表中,请用不带斜杠的写法重新包含目录(!/docs),以便 /docs/ 本身仍可访问。路由器把请求解析到的其他文件都不会接受判定,下一节会说明这一点。
判定的是请求路径,而不是文件
规则看到的是客户端请求的路径,而不是路由器把它解析到的文件。解析到另一个文件的请求按它自己的路径判定:
- 规则为
admin/时的/admin:在传统模式下,路由器用admin/index.php响应/admin,而/admin不是目录请求。 - 规则为
uploads/*.php时的/uploads/:目录索引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 无法接收请求 |
框架模式(ENTRY_FILE=index.php) |
是 | 前端控制器 |
工作进程模式(WORKER_MODE_ENABLED=true) |
是,回退须为状态码 | 工作进程脚本 |
回退
拒绝规则以 PHP_DENY_FALLBACK 响应,也就是 PHP_DENY_PATHS 所用的那个设置:一个 HTTP 状态码(默认 404;可与 ERROR_PAGES_DIR 搭配)或 DOCUMENT_ROOT 内的一个 PHP 脚本。该脚本会收到 OXPHP_DENIED_PATH(请求路径,带前导 /)和 OXPHP_DENIED_PATTERN(文件中所写的规则原文,例如 /composer.*)。
该脚本可以位于文件所拒绝的路径之下。例如 /_security/ 就能对直接请求隐藏它,而对它的直接请求由脚本自己作为一次拒绝来响应。只有当文件中至少有一条拒绝规则时(或在传统模式和 SPA 模式下 PHP_DENY_PATHS 生效时),才会读取 PHP_DENY_FALLBACK。
在工作进程模式下,回退必须是状态码:工作线程除了工作进程脚本之外什么都不运行。
当 .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。 - 对每个请求,每次拒绝都会在
debug级别记录request denied by .oxphpdeny,附带路径、规则及其行号。因深度或长度而被拒绝的请求会在debug级别记录request refused: path too deep or too long to match against .oxphpdeny,附带其路径段数和长度;这类请求不计数。
启动错误
| 条件 | 结果 |
|---|---|
没有 .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 的路径只会因深度被拒绝;超过之后,8 KiB 时 16 个路径段、约 43 KiB 时 3 个路径段就会用完这份预算。 - 一个文件最多 256 条规则,每个模式最多 128 字节,不计开头的
!或>。请求路径会逐条与规则匹配,并且对每一级父目录也要匹配,因此这些限制和上一条共同约束了单个请求的开销。典型的文件每个请求耗时远低于一毫秒。一个同时达到两项上限、刻意构造得很慢的文件(256 条以/x/**/*开头的长规则,配合专门构造、几乎能匹配它们的路径)每个请求约耗时 40 ms,并且每个 Tokio 工作线程会保留约 50 MB 的匹配状态。 - 关于大小写敏感性,参见 与
.gitignore的差异。 - 不支持重新加载。修改后需重启才能生效。
示例
一个从项目根目录提供服务的遗留应用:
/vendor
/composer.*
/config
*.sql
*.sql.gz
*.bak
*.log/readme.html
/license.txt
/wp-config-sample.php
/wp-content/debug.log在框架模式下,前端控制器收到 /storage/invoices/2026-07.pdf,检查会话后以流的方式输出该文件:
> /storage/invoices/给你的智能体的提示词
这些提示词假定你的智能体已连接 OxPHP 文档 MCP 服务器——需要哪些页面它会自己去读。
为这个仓库里的应用编写一个 `.oxphpdeny` 文件。通过 OxPHP 文档 MCP 服务器读取 `security/oxphpdeny` 页面。
弄清楚哪个目录是 `DOCUMENT_ROOT`、部署使用的是哪种路由模式,然后找出该目录下哪些内容永远不应被提供——`vendor/`、`composer.*`、配置文件、数据库转储、备份、日志、source map——以及其中哪些路径由应用自己提供、因而需要改用 `>` 规则。给我这个文件,每条规则都附上注释,并列出它无法覆盖的内容,因为规则匹配的是请求路径,而不是请求所解析到的文件。把我旧 Web 服务器配置中的拒绝规则移植到 `.oxphpdeny`。通过 OxPHP 文档 MCP 服务器读取 `security/oxphpdeny` 页面。
在这个仓库里找出 `deny all` 或 `return 403`/`404` 的 nginx `location` 块,以及拒绝访问的 `.htaccess` 规则,把每一条都翻译成一行 `.oxphpdeny`。告诉我哪些已经由点路径拦截覆盖,哪些是这个文件无法表达的。检查这个仓库里的 `.oxphpdeny` 文件有没有疏漏。通过 OxPHP 文档 MCP 服务器读取 `security/oxphpdeny` 页面。
对照这个应用的部署方式逐条检查规则:写了末尾斜杠、但目录索引仍能访问到的目录;含有 ASCII 以外字母、macOS 会以另一种拼写提供的名称;还需要配合 `PHP_DENY_PATHS` 的可写目录;以及会拦住 `/.well-known/acme-challenge/` 的允许列表。给我修正后的文件,并说明每处改动堵住了什么。另见
- PHP 执行拒绝列表 ——
PHP_DENY_PATHS与PHP_DENY_FALLBACK - 点路径拦截 —— 隐藏文件和目录,在这些规则运行之前就被拒绝
- 路由 —— 路由模式与路径安全
- 指标 ——
oxphp_path_deny_total