シンボリックリンク許可パス

デフォルトでは、OxPHP は正規の DOCUMENT_ROOT の外にあるパスへ解決されるリクエストをすべて拒否します。DOCUMENT_ROOT の内側にある外部ディレクトリを指すシンボリックリンクは 404 を返し、パス解決処理は Blocked request: resolved path escapes document root をログに記録します。

これは正しいデフォルトです。ディレクトリトラバーサル、シンボリックリンクの差し替えによる TOCTOU 攻撃、そして一つ上の階層にある設定ファイルやシークレットを誤って公開してしまうことを防ぎます。しかし同時に、フレームワークが何年も使ってきた正当なパターンもブロックしてしまいます。Laravel の php artisan storage:link、Symfony のアセットバンドル、複数のコンテナにマウントされた共有アップロードボリュームなどです。

SYMLINK_ALLOW_PATHS は明示的なオプトインです。DOCUMENT_ROOT 配下のシンボリックリンクが解決先として許可されるファイルシステムパスを列挙します。リストにないものは、厳格な 404 の挙動のままになります。

設定

bash
# 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 の例

bash
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 されます。

結果として得られた正規パスが許可リストとして保存されます。重複は暗黙のうちに除去されます。

リクエスト時には、ルーティング層が解決済みのファイルパスを正規化し、以下のいずれかを満たすことを検証します。

  1. DOCUMENT_ROOT の内側に存在する、または
  2. 許可リストのエントリーのいずれかと完全に一致する(ファイルターゲット)、または
  3. 許可リストのエントリーのいずれかに / が続いた形で始まる(ディレクトリターゲット)。

同じチェックは、TOCTOU ガードとして静的ファイル配信パスの内側でもう一度実行されます。ルートキャッシュの後、いかなる読み込み syscall よりも前です。

ブラックリスト

SYMLINK_ALLOW_PATHS には決して現れてはならない少数のパスがあります。タイプミスや誤解によって、さもなければ攻撃対象領域が劇的に広がってしまうためです。いずれかのエントリーがブラックリストのパスへ解決される場合、サーバーは起動を拒否します。

完全一致として禁止:

text
/ /etc /proc /sys /dev /var /home /tmp /root /usr /srv

プレフィックスとして禁止(エントリーが以下のいずれかのディレクトリの配下にある場合):

text
/etc /proc /sys /dev /tmp /root /usr
Note

/var/home/srv は完全一致のみです。むき出しの /srv は拒否されますが、/srv/myapp/storage は許可されます。/var/www/storage/home/<any>/... が許可されるのと同じです。エントリーは 2 回チェックされます。1 回目は管理者が指定した生のパスに対して(macOS スタイルの /etc -> /private/etc がブラックリスト対象のパスを realpath 経由でロンダリングできないようにするため)、2 回目は正規化された形式に対して(シンボリックリンクターゲットの脱出に対する多層防御)です。

ブラックリスト自体はハードコードされており、拡張するための環境変数はありません。このデフォルトは、タイプミスレベルの間違いを捕捉する保守的な最小限のものです。より厳格なポリシーが必要な管理者は、その外側で(ファイルシステムのパーミッション、コンテナのマウント制限、AppArmor/SELinux プロファイルによって)重ねて対応すべきです。

失敗モード

設定エラー 結果
エントリーのターゲットがディスク上に存在しない サーバーが起動を拒否し、エラーがそのエントリーと canonicalize を名指しする
エントリーがブラックリストに一致する(生または正規化) サーバーが起動を拒否し、エラーがそのエントリーとブラックリストのルールを名指しする
空/空白のみの環境変数 未設定として扱われる — 厳格なデフォルトの挙動
重複したエントリー 正規化後に暗黙のうちに除去される
起動時にまだシンボリックリンクが存在しない 許可リストは登録されるが、シンボリックリンクが現れるまでは効力を持たない。シンボリックリンクを必要とする起動時チェックはない

セキュリティに関する注記

  • 許可リストはオプトインです。変数が未設定のときは「脱出を許さない」という安全なデフォルトが保たれます
  • エントリーは起動時に正規化されるため、エントリーパス内の .. や途中のシンボリックリンクは保存前に折りたたまれます
  • 実行時の正規化がシンボリックリンク差し替えの TOCTOU の隙を塞ぎます。検証されたパスが、実際に読み込まれるパスです
  • ファイルターゲットは完全一致します。ディレクトリターゲットはディレクトリプレフィックスで一致します。/etc/passwd のファイルを列挙してもブラックリストによって依然として拒否されますが、より一般的に言えば、/opt/shared/license.key の単一ファイルを列挙しても、その兄弟ファイルへのアクセスが暗黙のうちに許可されることはありません
  • パス検証の結果はリクエストされた URL ごとにキャッシュされる(エビクションまで一意な URL につき realpath は 1 回)ため、実行時のコストは償却されて無視できるようになります

関連項目