パス拒否ルール(.oxphpdeny)

DOCUMENT_ROOT の最上位に置いた .oxphpdeny ファイルには、サーバーが直接配信してはならないリクエストパスを列挙します。書式は .gitignore の構文に 1 つだけ追加を加えたものです。> を前置したルールは、リクエストを拒否する代わりにアプリケーションのエントリースクリプトに渡します。

.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、データベースダンプやバックアップ、フレームワークの public/ にあるビルド成果物やソースマップなどです。ルールは 4 つのルーティングモードすべてで、静的ファイルにも PHP スクリプトにも同じように適用されます。ルールはディスクへのアクセスより前にチェックされるため、拒否されたパスはファイルが存在するかどうかにかかわらず同じ応答を受け取ります。

Note

このファイルは起動時に一度だけ読み込まれます。編集は次回の起動時に反映されます。ファイルがなければ何も変わりません。ファイル自体が配信されることはありません。先頭の /.well-known/ を除く、. で始まるすべてのパスセグメントと同様に、ルーティングより前にドットパスのブロックによって拒否されます。

構文

.gitignore がサポートするものは、.oxphpdeny もサポートします。

  • 空行と # で始まる行は読み飛ばされます。UTF-8 のバイトオーダーマークと CRLF の改行コードも受け付けます。
  • 末尾の空白は、エスケープ(\ )しない限り取り除かれます。
  • ! は、前のルールが除外したものを再び含めます。行頭の \# と \! は、リテラルの # と ! です。
  • 末尾の / は、ルールをディレクトリだけにマッチさせます。
  • 先頭または途中にある / は、パターンを DOCUMENT_ROOT の最上位に固定します。それがなければ、パターンは任意の深さでマッチします。vendor は /vendor も /lib/vendor もカバーします。
  • * と ? は 1 つのパスセグメント内にとどまります。[abc]、[a-z]、[!0-9]、[^0-9] は文字クラスです。git と同様に、? と文字クラスは 1 バイトにマッチし、文字クラスが / にマッチすることはありません。[!-] は、名前の中の - 以外の任意のバイトです。ASCII 外の文字は 2 バイト以上を占めるため、それを 1 文字として表せるのは * だけです。/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 と同様に、拒否を解除する ! ルールは、そのファイルを > ディレクトリの扱いに委ねます。*.sql の後に !/storage/invoices/dump.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 はサーバーと同じく文字クラスを 1 バイトに対してマッチさせますが、そのような文字は 2 バイト以上を占めます。そのため /[äÄ]dmin は /ädmin も /Ädmin もカバーせず、/[äÄ]* は同じバイトで始まる /öffnungszeiten をカバーしてしまいます。その位置には * を置いてください。
  • エスケープされた / があると起動が中止されます(**\/secret.txt、\/vendor)。git は **\/secret.txt を、少なくとも 1 つのディレクトリの配下にある 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/ から配信します。そのため、1 つの綴りにしかマッチしないルールでは、ほかの綴りが素通りしてしまいます。*.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、合字 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 ファイルシステムはそれぞれの綴りを別のファイルとして扱うため、これが問題になるのは、どちらの組も 1 つのファイルとして扱う macOS です。macOS から Docker Desktop へのバインドマウントを経由する場合も含みます。そこでは、もう一方の綴りへのリクエストがディスクから配信されてしまいます。? と文字クラスは 1 バイトにマッチするため、そのような文字の位置には * を置く(/caf*.txt)か、それらのファイルを DOCUMENT_ROOT の外に置いてください。
  • ルールは最大 256 個、それぞれ最大 128 バイトです。 いずれかの上限を超えるルールがあると、その行番号とともに起動が中止されます(制限を参照)。
  • ファイルは DOCUMENT_ROOT の最上位にある 1 つだけで、起動時に読み込まれます。 サブディレクトリにあるファイルは読み込まれません。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 — — 拒否 拒否

/ 単体がマッチすることはないため、許可リスト形式のファイルでもサイトのルートは配信されます。

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

サイトが ACME HTTP-01 で TLS 証明書を更新する場合は、/.well-known/ の行を残してください。これらがないと、/.well-known/acme-challenge/<token> が拒否されます。

リクエストはほかに 2 通りの読み方でも判定され、そのどちらかで拒否されれば拒否が確定します。

  • ディレクトリリクエストは、それが指すファイルとしても判定されます。 ルーターは末尾のスラッシュを取り除き、/secret.txt/ をファイル secret.txt として配信することがあります。
  • 従来型モードでは、後ろにさらにパスが続く .php セグメントは、それが指すスクリプトとして判定されます。 /secret.php/x.js は PATH_INFO /x.js で secret.php を実行するため、/secret.php が拒否される場所ではどこでも拒否されます。

いずれの場合も、すべてのディレクトリを再び含める gitignore の書き方である !*/ では、拒否されたファイルを元に戻せません。許可リストで /docs/ 自体に到達できるようにしておくには、ディレクトリをスラッシュなしで再び含めてください(!/docs)。次のセクションで示すとおり、ルーターがリクエストを解決した先のファイルがほかに判定されることはありません。

ファイルではなくリクエストパス

ルールが見るのは、ルーターが解決した先のファイルではなく、クライアントが要求したパスです。別のファイルに解決されるリクエストは、そのリクエスト自身のパスで判定されます。

  • ルール admin/ に対する /admin: 従来型モードでは、ルーターは /admin に admin/index.php で応答しますが、/admin はディレクトリリクエストではありません。
  • ルール uploads/*.php に対する /uploads/: リクエストパスが /uploads/ なので、ディレクトリインデックスの uploads/index.php が実行されます。
  • /x.php/y.png(PATH_INFO)は、パスが .png で終わっているにもかかわらず x.php を実行します。スクリプトがパスの中で指定されているため、前述のとおり、従来型モードはこのリクエストを /x.php としても判定します。
Tip

ディレクトリを閉じるには、末尾のスラッシュなしで書いてください(/admin)。書き込み可能なディレクトリでの .php の実行を止めるには、PHP_DENY_PATHS も併用してください。こちらはリクエストの解決先のスクリプトもチェックします。

ルーティングモード

ルーティングモード 拒否ルール > ルール
従来型(ENTRY_FILE なし) はい 起動エラー: エントリースクリプトがない
SPA(ENTRY_FILE=index.html) はい 起動エラー(index.html はリクエストを受け取れないため)
フレームワーク(ENTRY_FILE=index.php) はい フロントコントローラー
ワーカー(WORKER_MODE_ENABLED=true) はい(フォールバックはステータスコード) ワーカーのエントリー

フォールバック

拒否ルールは、PHP_DENY_PATHS が使うのと同じ設定である PHP_DENY_FALLBACK で応答します。HTTP ステータス(デフォルトは 404。ERROR_PAGES_DIR と組み合わせられます)か、DOCUMENT_ROOT 内の PHP スクリプトです。スクリプトは OXPHP_DENIED_PATH(先頭に / が付いたリクエストパス)と OXPHP_DENIED_PATTERN(ファイルに書かれたとおりのルール。例: /composer.*)を受け取ります。

スクリプトは、ファイルが拒否するパスの配下に置いてもかまいません。/_security/ を拒否すれば直接のリクエストからスクリプトを隠せます。そのスクリプトへの直接のリクエストには、拒否としてスクリプト自身が応答します。PHP_DENY_FALLBACK が読み込まれるのは、ファイルに拒否ルールが 1 つ以上ある場合(または従来型モードと SPA モードで PHP_DENY_PATHS が有効な場合)だけです。

Note

ワーカーモードでは、フォールバックはステータスコードでなければなりません。ワーカースレッドはワーカーのエントリー以外を実行しないからです。

.oxphpdeny と PHP_DENY_PATHS の両方がリクエストにマッチした場合は、.oxphpdeny が応答します。

可観測性

  • oxphp_path_deny_total メトリクスは、ルールによって拒否されたリクエストを数えます。> ルールは拒否ではなくルーティングなので、カウントされません。oxphp_php_deny_total は引き続き PHP_DENY_PATHS だけを数えます。両方が拒否したリクエストは、oxphp_path_deny_total で 1 回だけカウントされます。
  • /config では、deny_file エントリーがアクション別のルール数とフォールバックの種類を示しますが、ルールそのものは示しません(内部サーバーを参照)。
  • 起動ログには loaded 5 rules (3 deny, 1 entry, 1 allow) from /var/www/html/public/.oxphpdeny と出力されます。LOG_LEVEL=debug では、ルールごとに 1 行ずつ出力される 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 と、1 つ以上の拒否ルール 起動エラー
空のファイル、コメントのみ、または ! ルールのみ 拒否する対象がない: ルーティングは深さの制限も含めて変わらない。/config ではファイルが読み込み済みとしてルール数とともに表示される

oxphp config --check は、デプロイ前にファイルのエラーを報告します(config: INVALID)。フォールバックのチェックは、起動時にサーバーがルーティングを構築するときに実行されます。

制限

  • パスの深さと長さには上限があります。拒否ルールまたは > ルールが 1 つ以上ある場合、64 セグメントより深いリクエストパスや、セグメント数と長さの積が 128 KiB を超えるリクエストパスは、マッチングされずに 404 で応答されます。マッチングはすべての親ディレクトリを再チェックし、それぞれのパスを走査するため、そうしなければコストは深さと長さの両方に応じて増えてしまいます。2 KiB までのパスが拒否されるのは深さが理由の場合だけです。それを超えると、8 KiB なら 16 セグメント、約 43 KiB なら 3 セグメントで上限に達します。
  • 1 つのファイルに書けるルールは最大 256 個で、各パターンは先頭の ! や > を除いて最大 128 バイトです。リクエストパスはルールと 1 つずつ照合され、各親ディレクトリについても照合されるため、これらの制限と前述の制限によって 1 リクエストあたりのコストが抑えられます。一般的なファイルでは、1 リクエストあたり 1 ミリ秒を大きく下回ります。両方の上限に達し、意図的に遅くなるよう作られたファイル(/x/**/* で始まる長いルール 256 個に対し、それらにほぼマッチするよう作られたパスを送る場合)では、1 リクエストあたり約 40 ms かかり、Tokio のワーカースレッドごとに約 50 MB のマッチ状態を保持します。
  • 大文字・小文字の区別については、.gitignore との違いを参照してください。
  • リロードの仕組みはありません。編集を反映するには再起動してください。

例

プロジェクトルートから配信されるレガシーアプリケーションの例です。

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

エージェント向けプロンプト

これらのプロンプトは、OxPHP ドキュメント MCP サーバー を接続したエージェントを前提としています — 必要なページはエージェントが自分で読み取ります。

prompt
このリポジトリのアプリケーション用に `.oxphpdeny` ファイルを書いてください。OxPHP ドキュメント MCP サーバー経由で `security/oxphpdeny` ページを読んでください。 どのディレクトリが `DOCUMENT_ROOT` で、デプロイがどのルーティングモードを使っているのかを突き止めてから、そのディレクトリの配下で決して配信してはならないもの(`vendor/`、`composer.*`、設定ファイル、データベースダンプ、バックアップ、ログ、ソースマップ)と、そのうちアプリケーションが自分で配信するため代わりに `>` ルールが必要なパスを見つけてください。各ルールにコメントを付けたファイルを示し、ルールが解決先のファイルではなくリクエストパスにマッチするためにカバーできないものがあれば列挙してください。
prompt
私の古い Web サーバー設定にある拒否ルールを `.oxphpdeny` に移植してください。OxPHP ドキュメント MCP サーバー経由で `security/oxphpdeny` ページを読んでください。 このリポジトリの中から、`deny all` や `return 403`/`404` を行う nginx の `location` ブロックと、アクセスを拒否する `.htaccess` のルールを見つけ、それぞれを `.oxphpdeny` の 1 行に変換してください。どれがすでにドットパスのブロックでカバーされていて、どれがこのファイルでは表現できないのかを教えてください。
prompt
このリポジトリの `.oxphpdeny` ファイルに抜けがないかレビューしてください。OxPHP ドキュメント MCP サーバー経由で `security/oxphpdeny` ページを読んでください。 このアプリケーションのデプロイ方法に照らして各ルールを確認してください。末尾にスラッシュを付けて書かれているのにディレクトリインデックスから依然として到達できるディレクトリ、macOS が別の綴りで配信してしまう ASCII 外の文字を含む名前、`PHP_DENY_PATHS` も必要な書き込み可能ディレクトリ、`/.well-known/acme-challenge/` をブロックしてしまう許可リストなどです。修正したファイルを示し、各変更がどの穴を塞ぐのかを説明してください。

関連項目