符号链接允许路径
默认情况下,OxPHP 会拒绝任何解析到规范 DOCUMENT_ROOT 之外路径的请求。DOCUMENT_ROOT 内部指向外部目录的符号链接会返回 404,并且路径解析会记录日志 Blocked request: resolved path escapes document root。
这是正确的默认行为:它能阻止目录遍历,以及意外暴露位于上一级目录的配置文件或密钥。但它同时也会拦截框架多年来一直在使用的合法模式:Laravel 的 php artisan storage:link、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。指向 /storage/<file> 的 URL 会通过该符号链接解析到 /app/storage/app/public/<file>,而它经过规范化后得到的路径正是允许列表所授权的。应用程序无需改动任何代码。
工作原理
在启动时,每一条目都会被解析:
- 绝对路径条目——先与黑名单(见下文)比对,再经过
realpath(3)(即std::fs::canonicalize)处理。如果realpath失败(目标不存在),服务器将拒绝启动。 - 相对路径条目——与规范的
DOCUMENT_ROOT拼接,再执行realpath。
得到的规范路径会被存储为允许列表。重复项会被静默去重。
在请求时,路由层会对解析出的文件路径进行规范化,并验证它满足以下条件之一:
- 位于
DOCUMENT_ROOT内部,或 - 恰好等于允许列表中的某一条目(文件目标),或
- 以允许列表中的某一条目后接
/开头(目录目标)。
路由检查会按路径缓存其判定结果,因此最终被读取的文件会再次接受检查。
DOCUMENT_ROOT 只在启动时解析一次,从磁盘读取的静态文件和 PHP 脚本在每次被打开时都会与该位置进行比对。因此,经由发布符号链接到达的根目录(例如 Capistrano 或 Deployer 风格布局中的 /app/current/public),一旦 current 切换到新的发布版本,对这两类文件都会返回 404,直到服务器重启为止。从内存缓存提供的文件会继续返回其缓存的字节。请将 DOCUMENT_ROOT 指向一个不随发布而变化的路径,或者在切换时一并重启服务器。
静态文件
对于静态文件,该检查会在提供服务时再运行一次,作用于实际打开的文件,并且发生在读取它的任何字节之前。文件位置取自已打开的描述符(Linux 上为 /proc/self/fd/N,macOS 上为 F_GETPATH),因此即使符号链接是在经过缓存的路由检查之后才被替换的,仍然会被拒绝。在无法获取描述符路径的情况下,会重新解析该路径,并且它必须指向与被打开的文件相同的文件。
从内存缓存应答的文件不会被重新打开。被替换的链接会继续返回从其先前已验证的目标缓存下来的字节,直到该缓存条目被逐出,或者在 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=. 这样的相对条目指的是脚本所在的目录,而预置文件通过 ini_set() 收窄的 open_basedir 会作用于它所预置的那个脚本。这也包括 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 脚本都是针对已打开的文件进行检查的,因此在挂载了
/proc的 Linux 上以及在 macOS 上,符号链接在被提供过一次之后再被替换,也无法让之后的响应来自允许位置之外,或者运行允许位置之外的脚本。其他环境中使用的回退机制覆盖范围更窄,如上文所述 - 文件目标精确匹配,目录目标按目录前缀匹配。将
/etc/passwd这样的文件列入仍会被黑名单拒绝,但更一般地说:将单个文件/opt/shared/license.key列入并不会隐式授予对其同级文件的访问权限 - 路由阶段的验证结果按请求的 URL 缓存(每个唯一 URL 在被逐出前只执行一次
realpath)。提供服务时的检查会为每个从磁盘读取的静态文件增加一次对已打开描述符位置的查询,对从内存缓存提供的文件则没有任何额外开销。对于工作进程模式之外的 PHP 脚本,它会为每个请求增加大约八次系统调用(在 PHP 8.4 上用strace统计):对脚本的一次open和一次close、上述查询,以及对描述符的fstat、fcntl和lseek调用。这也包括由 OPcache 应答的请求,在opcache.validate_timestamps关闭时,这类请求原本根本不会触碰该文件。在一个只输出一个单词的脚本上,测得的开销约为吞吐量的 3%;真实应用的请求中花在这上面的比例会相应更小
给你的智能体的提示词
这些提示词假定你的智能体已连接 OxPHP 文档 MCP 服务器——需要哪些页面它会自己去读。
为我的应用所依赖的 storage 符号链接配好 `SYMLINK_ALLOW_PATHS`。通过 OxPHP 文档 MCP 服务器读取 `security/symlink-allow-paths` 页面。
找出我文档根目录下的符号链接(比如 `storage:link` 这一类链接)和它们的真实目标,然后给我允许列表条目,并标出任何会撞上黑名单的目标。