PHP での安全な画像アップロード
アップロード用のエンドポイントは、見知らぬ人がサーバーにファイルを書き込めるようにするものです。PHP で起こりうる最悪の事態ははっきりしています。後で Web サーバーが PHP として実行してしまうファイルです。このページでは、サンプルの rte/upload.php にあるチェックを順に確認し、それぞれが防げることと防げないことを示します。そして最後に、もっとも重要なルールを紹介します。uploads/ にあるものは決して実行させてはいけない、というルールです。
ブラウザーの申告を信用しない
$_FILES['file']['name'] と $_FILES['file']['type'] は、どちらもリクエストから来る値です。クライアントは任意の名前と型を送信できます。当社のテストでは、<?php echo "hello"; ?> を含むファイルを、holiday.png という名前で type=image/png を付けて送信しました。type をチェックするだけなら、これは受け付けられていたはずです。サンプルは type を完全に無視し、代わりにバイト列を調べます。
// The name decides the extension; the bytes must agree with it.
// $file['type'] is whatever the browser (or attacker) sent, so it is ignored.
$ext = rte_extension((string)($file['name'] ?? ''));
if (!in_array($ext, RTE_IMAGE_EXTENSIONS, true)) {
throw new RteHttpError(400, 'Only .jpg, .jpeg, .png, .gif and .webp images can be uploaded.');
}
$types = ['jpg' => IMAGETYPE_JPEG, 'jpeg' => IMAGETYPE_JPEG, 'png' => IMAGETYPE_PNG, 'gif' => IMAGETYPE_GIF, 'webp' => IMAGETYPE_WEBP];
// Signature first, then getimagesize() (core PHP, no GD needed) parses the header.
// A bare signature followed by junk still parses, but as 0x0, so require real dimensions.
$info = @getimagesize($file['tmp_name']);
if (!rte_signature_matches($file['tmp_name'], $ext) || $info === false
|| $info[2] !== $types[$ext] || $info[0] < 1 || $info[1] < 1) {
throw new RteHttpError(400, 'The file is not a valid .' . $ext . ' image.');
}拡張子は 5 つのうちのいずれかでなければならず、ファイルの先頭バイトはそれと一致しなければなりません。
// The first bytes of the file must match its extension.
function rte_signature_matches(string $file, string $ext): bool
{
$handle = fopen($file, 'rb');
if ($handle === false) {
return false;
}
$head = (string)fread($handle, 12);
fclose($handle);
return match ($ext) {
'jpg', 'jpeg' => str_starts_with($head, "\xFF\xD8\xFF"),
'png' => str_starts_with($head, "\x89PNG\r\n\x1A\n"),
'gif' => str_starts_with($head, 'GIF87a') || str_starts_with($head, 'GIF89a'),
'webp' => str_starts_with($head, 'RIFF') && substr($head, 8, 4) === 'WEBP',
default => false,
};
}getimagesize() と 0 x 0 のチェックを行う理由
getimagesize() は画像のヘッダーを読み取り、種類と寸法を返します。PHP 本体の関数なので、GD や fileinfo がなくても動作します。ただし、これだけなら簡単にすり抜けられます。PNG のシグネチャの後に 3 MB のゼロが続くファイルは、幅と高さが 0 の image/png として getimagesize() を通過しました。そのため、サンプルでは両方の寸法が 1 以上であることも求めています。
どちらのチェックも、ファイルを無害にするものではありません。実在する 1×1 の PNG の末尾に <?php echo "EXECUTED"; ?> を追加してみました。シグネチャも寸法も有効で、PHP のコードはそのまま中に残っています。サンプルは、ほかのどんな画像チェックでもそうであるように、これを保存しました。それが問題にならないのは、次の 2 つのルールがあるからです。
生成した名前
保存名はサーバーが作ります。元の名前を小文字にして a-z、0-9、- だけに絞り込み、その後にランダムな 16 進数 8 桁、そしてチェック済みの拡張子を付けます。Red Square.png は red-square-abc60cfb.png のような名前になります。そのため、サーバーが 2 つ目のドットを含む名前(shell.php.png)を書き込むことも、既存のファイルを上書きすることも、クライアントが選んだ名前をそのまま使うこともありません。shell.php というファイルは、それより前の段階で 400 {"error":"Only .jpg, .jpeg, .png, .gif and .webp images can be uploaded."} として拒否されます。

holiday.jpg という名前のスクリプト。ダイアログは独自のメッセージを表示します。サーバーが示した理由はレスポンスの本文にあります。uploads/ にあるものは決して実行しない
このルールがあるからこそ、ほかのチェックに不備があっても安全でいられます。Web サーバーを、uploads/ のファイルを必ずバイト列として送信するだけになるように設定してください。Apache では、サンプルの uploads.htaccess を uploads/.htaccess にコピーするか、できれば同じ行を vhost の <Directory> 内に記述します。
# 1. Only image files can be requested. Everything else (x.php, x.phtml, .htaccess) -> 403,
# whatever handler (mod_php, PHP-FPM, CGI) the server uses for .php.
Require all denied
<FilesMatch "(?i)\.(jpe?g|png|gif|webp)$">
Require all granted
</FilesMatch>
# 2. Even an allowed name must not reach PHP. "AddHandler ... .php" also matches
# "x.php.png" (mod_mime looks at every extension), so drop those mappings here.
RemoveHandler .php .phtml .phar .php3 .php4 .php5 .php7 .php8 .pht .phps
RemoveType .php .phtml .phar .php3 .php4 .php5 .php7 .php8 .pht .phps
<IfModule mod_php.c>
php_flag engine off
</IfModule>
Options -Indexes -ExecCGI -Includes
# 3. Browsers must not guess a different type from the bytes.
<IfModule mod_headers.c>
Header always set X-Content-Type-Options "nosniff"
</IfModule>RemoveHandler の行は、Apache の古典的な落とし穴に対応するものです。AddHandler application/x-httpd-php .php は名前に含まれるすべての拡張子に一致するため、photo.php.png が PHP として実行されてしまいます。nginx では、^~ プレフィックスが重要な部分です。
# ^~ : once a URL starts with /uploads/, nginx skips regex locations, so a
# "location ~ \.php$" block can never run an uploaded file.
location ^~ /uploads/ {
deny all;
location ~* \.(jpe?g|png|gif|webp)$ {
allow all;
add_header X-Content-Type-Options nosniff always;
}
}
# Nothing else is PHP-executable (rte/files-lib.php, router.php, demo-login.php).
location ~ \.php$ { return 404; }^~ がないと、設定のどこかにある通常の location ~ \.php$ { fastcgi_pass ...; } が単純な location /uploads/ より優先され、/uploads/x.php が実行されます。また、PHP-FPM のプールでは security.limit_extensions = .php を維持してください。そうすれば、ほかのルールが photo.png を渡してきても、FPM はその実行を拒否します。
PHP の組み込み開発サーバーにも、形は違いますが同じ落とし穴があります。ルータースクリプトが false を返すと、サーバーがファイルを自分で処理し、.php ファイルを実行してしまいます。サンプルのルーターは /uploads/ を自分で配信し、画像だけを返します。確認したところ、/uploads/probe.php は 404 になり、/uploads/probe.php.png は実行されずに image/png のバイト列として返されました。
組み込みサーバーの構成は、Windows 上の PHP 8.2 でテストしました。上記の Apache と nginx のルールは、そのマシンにどちらのサーバーもインストールされていないため、実行していません。自分のサーバーでテストしてください。uploads/ に test.php を置いてリクエストし、403 または 404 が返れば問題ありません。その出力が返ってきてはいけません。
フォルダーとパス
folder フィールドは仮想パスで、部分ごとにチェックした後、ストレージルートの中で realpath() によって解決されます。これにより、..、絶対パス、ドライブ文字、バックスラッシュ、外部を指すシンボリックリンクやジャンクションが拒否されます。コードと、Windows のジャンクションで観察した結果については、フォルダーのガイドを参照してください。
サンプルが対応していないこと
サンプルにはサインインの仕組みがなく、2 つの POST ルートには CSRF トークンもありません。rte-files.js は、追加のヘッダーを付けずに通常の XMLHttpRequest を送信します。そのため、Laravel の web ルートのように、すべての POST で CSRF ミドルウェアが実行されるフレームワークでは、これらのルートを除外するか、自分でトークンを追加しない限り、リクエストが拒否されます。ユーザーのチェックは rte_root_for_request() より前に行ってください。その場所はユーザーごとのフォルダーのガイドで紹介しています。
テストする
このガイドのサンプルプロジェクトをポート 5201 で実行した状態で、以下を実行します。
printf '<?php echo "hello"; ?>' > holiday.png
curl -F "file=@holiday.png;type=image/png" http://localhost:5201/rte/upload
{"error":"The file is not a valid .png image."}
curl -F "file=@holiday.png;filename=shell.php" http://localhost:5201/rte/upload
{"error":"Only .jpg, .jpeg, .png, .gif and .webp images can be uploaded."}どちらのレスポンスも HTTP 400 です。共通の契約テストは、同じ種類のファイル(.png という名前のスクリプト、.svg、.php、トラバーサルを含むフォルダー)を送信し、それぞれが拒否されることを確認します。node shared/contract-test.mjs http://localhost:5201 を実行すると、最後に ALL 35 CHECKS PASSED と表示されます。
ほかのプラットフォーム向けの同じガイド
JavaScript · Next.js · ASP.NET Core · Razor Pages · MVC 5 · Web Forms