シンボリックリンク許可パス
デフォルトでは、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 が新しいリリースに切り替えられると、サーバーが再起動するまで静的ファイルと PHP スクリプトの両方が 404 を返します。インメモリキャッシュから配信されるファイルは、保持しているバイト列で応答し続けます。DOCUMENT_ROOT はリリースをまたいで変わらないパスに向けるか、切り替えの一環としてサーバーを再起動してください。
静的ファイル
静的ファイルの場合、チェックは配信時にもう一度、実際に開かれたファイルに対して、そのファイルのいかなるバイトが読み込まれるよりも前に実行されます。場所は開いたディスクリプタから取得されるため(Linux では /proc/self/fd/N、macOS では F_GETPATH)、キャッシュされたルーティングチェックの後に差し替えられたシンボリックリンクも拒否されます。ディスクリプタのパスが取得できない環境では、パスが再度解決され、それが開かれたファイルと同じファイルを指していなければなりません。
インメモリキャッシュから応答されるファイルは再度開かれません。差し替えられたリンクは、エントリーが退避されるまで、あるいは STATIC_REVALIDATE=on の場合は再検証が変更に気づくまで、以前の検証済みターゲットからキャッシュされたバイト列を返し続けます。
PHP スクリプト
PHP スクリプトも、ワーカーモードを除くすべてのモードで同じ扱いを受けます。ワーカーモードのエントリーファイルは、管理者が設定したパスだからです。ワーカーはスクリプトを自ら開き、開いたディスクリプタの場所を同じ方法でチェックし、パスではなくその開いたファイルをエンジンに渡します。そのため、開かれるファイルはチェックされたファイルそのものです。例外が 1 つあります。解決後の名前に .phar を含み、zip、tar、または圧縮された phar アーカイブであるスクリプトは、phar 拡張によって名前でもう一度開かれ、その 2 回目のオープンはチェックの対象外です。
| 配信時のスクリプト | 結果 |
|---|---|
| 通常のファイルではない(ディレクトリ、FIFO)、または存在するが読み込めない | 500、Cannot open script をログに記録する。PHP 自身の failed-to-open 警告と致命的エラーは発生しない。 |
| ルーティング後に削除された | 404。OPcache が保持しているスクリプトも含む。opcache.validate_timestamps がオフの場合、そうしたスクリプトは本来ならファイルを開かずにキャッシュから実行される |
| ルーティング後にリンクの向き先が、スクリプト、またはエンジンがそのまま出力してしまうテキストファイルに変更された | 404、Blocked request: opened script escapes document root をログに記録する |
リンクの向き先が DOCUMENT_ROOT 内の別のスクリプト、または許可リストにあるターゲットに変更された |
そのスクリプトを実行する |
__FILE__、__DIR__、$_SERVER['SCRIPT_FILENAME'] は、チェックがない場合と同じです。エンジンにはファイルの解決済みの場所が渡されるため、次の 3 つのことが起こります。
- 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>/... が許可されるのと同じです。エントリーは 2 回チェックされます。1 回目は管理者が指定した生のパスに対して(macOS スタイルの /etc -> /private/etc がブラックリスト対象のパスを realpath 経由でロンダリングできないようにするため)、2 回目は正規化された形式に対して(シンボリックリンクターゲットの脱出に対する多層防御)です。
ブラックリスト自体はハードコードされており、拡張するための環境変数はありません。このデフォルトは、タイプミスレベルの間違いを捕捉する保守的な最小限のものです。より厳格なポリシーが必要な管理者は、その外側で(ファイルシステムのパーミッション、コンテナのマウント制限、AppArmor/SELinux プロファイルによって)重ねて対応すべきです。
失敗モード
| 設定エラー | 結果 |
|---|---|
| エントリーのターゲットがディスク上に存在しない | サーバーが起動を拒否し、エラーがそのエントリーと canonicalize を名指しする |
| エントリーがブラックリストに一致する(生または正規化) | サーバーが起動を拒否し、エラーがそのエントリーとブラックリストのルールを名指しする |
| 空/空白のみの環境変数 | 未設定として扱われる — 厳格なデフォルトの挙動 |
| 重複したエントリー | 正規化後に暗黙のうちに除去される |
| 起動時にまだシンボリックリンクが存在しない | 許可リストは登録されるが、シンボリックリンクが現れるまでは効力を持たない。シンボリックリンクを必要とする起動時チェックはない |
セキュリティに関する注記
- 許可リストはオプトインです。変数が未設定のときは「脱出を許さない」という安全なデフォルトが保たれます
- エントリーは起動時に正規化されるため、エントリーパス内の
..や途中のシンボリックリンクは保存前に折りたたまれます - 静的ファイルと PHP スクリプトは開かれたファイルに対してチェックされるため、
/procがマウントされた Linux と macOS では、一度配信された後にシンボリックリンクを差し替えても、以降のレスポンスを許可された場所の外から返させたり、許可された場所の外にあるスクリプトを実行させたりすることはできません。それ以外の環境で使われるフォールバックは、上で説明したとおり、より限定的です - ファイルターゲットは完全一致します。ディレクトリターゲットはディレクトリプレフィックスで一致します。
/etc/passwdのファイルを列挙してもブラックリストによって依然として拒否されますが、より一般的に言えば、/opt/shared/license.keyの単一ファイルを列挙しても、その兄弟ファイルへのアクセスが暗黙のうちに許可されることはありません - ルーティング時の検証はリクエストされた URL ごとにキャッシュされます(エビクションまで一意な URL につき
realpathは 1 回)。配信時のチェックは、ディスクから読み込まれる静的ファイルごとに、開いたディスクリプタの場所の参照を 1 回追加し、インメモリキャッシュから配信されるファイルには何も追加しません。ワーカーモード以外の PHP スクリプトでは、すべてのリクエストに約 8 回のシステムコールを追加します(PHP 8.4 上でstraceを使って計測)。スクリプトのopenとclose、その参照、そしてディスクリプタに対するfstat、fcntl、lseekの呼び出しです。これには OPcache が応答するリクエストも含まれます。そうしたリクエストは、opcache.validate_timestampsがオフの場合、本来ならファイルに触れません。1 単語を echo するだけのスクリプトでは、これはスループットの約 3% に相当しました。実際のアプリケーションでは、リクエストに占めるこのコストの割合はそれに比例して小さくなります
エージェント向けプロンプト
これらのプロンプトは、OxPHP ドキュメント MCP サーバー を接続したエージェントを前提としています — 必要なページはエージェントが自分で読み取ります。
私のアプリケーションが依存しているストレージのシンボリックリンク向けに `SYMLINK_ALLOW_PATHS` を設定してください。OxPHP ドキュメント MCP サーバー経由で `security/symlink-allow-paths` ページを読んでください。
私のドキュメントルート配下にあるシンボリックリンク(`storage:link` で作られるようなもの)と、その実際のターゲットを洗い出したうえで、許可リストのエントリーを教えてください。ブラックリストに当たってしまうターゲットがあれば指摘してください。関連項目
- PHP 拒否パス — 特定の URI グロブの配下での PHP 実行をブロックする。シンボリックリンクポリシーとは直交する
- ドットパスのブロック —
.well-knownスタイルのトラバーサルとドットファイルの漏洩を拒否する - 信頼済みプロキシ —
X-Forwarded-*ヘッダーに対する別個の信頼境界 - 設定リファレンス — すべての環境変数