PHP での画像アップロードのサイズ制限
大きな写真の「Upload failed」は、5 か所のどこからでも発生する可能性があります。PHP ではそのうち 2 か所が php.ini にあり、スクリプトが実行される前に作用します。このページでは、リクエストが通過する順に各レイヤーと、それぞれが返すレスポンスを紹介します。また、設定を変えない限り、きれいな 413 を HTML ページに変えてしまう PHP の動作についても説明します。
各レイヤーとその順序
- ギャラリーダイアログ。エディターのオプションで
maxUploadFileSize: 4000000を指定すると、ダイアログはそれより大きなファイルを送信前に拒否します。4.5 MB のファイルでは「big.jpg is 4.3 MB. The maximum allowed size is 3.8 MB.」と表示されました(ダイアログは 2 進数のメガバイトで計算します)。これは利便性のための機能で、サーバーはこれに頼ることはできません。 - Web サーバー。nginx のドキュメントでは、
client_max_body_sizeの既定値は 1m とされているため、2 MB の写真は PHP に届く前に、nginx 独自の HTML の 413 ページで拒否されます。Apache で同じ役割を果たすのはLimitRequestBodyです。サンプルのnginx.conf.exampleではclient_max_body_size 5m;を設定しています。 post_max_size(既定値は 8M)は、リクエストの本文全体を制限します。upload_max_filesize(既定値は 2M)は、ファイル 1 つごとのサイズを制限します。- 独自のチェック。サンプルでは 4,000,000 バイトで、JSON の 413 を返します。
サンプルの制限が適用されるようにするには、それより前のすべてのレイヤーで 4,000,000 バイトより少し大きいサイズまで許可しておく必要があります。マルチパートのエンコードによって、ファイルの前後に数百バイトが加わるためです。
upload_max_filesize = 5M
post_max_size = 8M
display_errors = Off
log_errors = Onスクリプトがそれらを区別する方法
// Over post_max_size, PHP drops the WHOLE body: $_POST and $_FILES are both empty.
$length = (int)($_SERVER['CONTENT_LENGTH'] ?? 0);
if (empty($_FILES) && empty($_POST) && $length > rte_ini_bytes('post_max_size')) {
throw new RteHttpError(413, 'The upload is larger than the server accepts (post_max_size).');
}
$file = $_FILES['file'] ?? null;
// One file only: "file[]" arrives as nested arrays, which this refuses.
if (!is_array($file) || !is_int($file['error'] ?? null) || !is_string($file['tmp_name'] ?? null)) {
throw new RteHttpError(400, 'No file received. Send it as the "file" field.');
}
switch ($file['error']) {
case UPLOAD_ERR_OK:
break;
case UPLOAD_ERR_INI_SIZE: // larger than upload_max_filesize
case UPLOAD_ERR_FORM_SIZE: // larger than a MAX_FILE_SIZE form field
throw new RteHttpError(413, 'The image is larger than the server accepts.');
case UPLOAD_ERR_PARTIAL:
throw new RteHttpError(400, 'The upload was interrupted. Please try again.');
case UPLOAD_ERR_NO_FILE:
throw new RteHttpError(400, 'No file received.');
default: // NO_TMP_DIR, CANT_WRITE, EXTENSION: server-side problems
error_log('rte upload: UPLOAD_ERR code ' . $file['error']);
throw new RteHttpError(500, 'The server could not store the upload.');
}
if (!is_uploaded_file($file['tmp_name'])) {
throw new RteHttpError(400, 'No file received.');
}
$size = filesize($file['tmp_name']);
if ($size === false || $size === 0) {
throw new RteHttpError(400, 'The file is empty.');
}
if ($size > RTE_MAX_BYTES) {
throw new RteHttpError(413, 'Images can be at most 4,000,000 bytes.');
}upload_max_filesize を超えた場合でも、PHP はスクリプトを実行し、$_FILES['file']['error'] は UPLOAD_ERR_INI_SIZE になります。PHP の既定値 2M で動いているサーバーに 3 MB のファイルを送信したところ、413 {"error":"The image is larger than the server accepts."} が返りました。
post_max_size を超えた場合、PHP は本文全体を破棄します。$_POST と $_FILES はどちらも空の配列になり、何も送信されなかったかのように見えます。それでもスクリプトは実行され、$_SERVER['CONTENT_LENGTH'] には実際のサイズが入ったままです。何が起きたのかを知るには、この組み合わせを見るしかないため、サンプルはほかの何よりも先にこれをチェックします。このチェックを省くと、制限を超えたアップロードは「ファイルが届いていない」ように見えます。
display_errors の落とし穴
リクエストが post_max_size を超えると、PHP は警告も発生させます。しかも、それはスクリプトの最初の行より前に起こります。display_errors が有効な場合(php.ini が読み込まれていないときの PHP の組み込みの既定値であり、php.ini-development の設定でもあります)、この警告がレスポンス本文の先頭になります。その結果、http_response_code(413) は「Cannot modify header information」で失敗し、ブラウザーは次のものを受け取ります。
HTTP/1.1 200 OK
Content-type: text/html; charset=UTF-8
<br />
<b>Warning</b>: POST Content-Length of 9000204 bytes exceeds the limit of 8388608 bytes in <b>Unknown</b> on line <b>0</b><br />
...
{"error":"The upload is larger than the server accepts (post_max_size)."}出力はスクリプトが始まる前に送信されているため、スクリプトではこれを修正できません。エラーの表示を無効にし、代わりにログに記録してください。当社のテストでは、PHP の組み込みサーバーで -d display_errors=stderr を指定しても効果がありませんでした。Content-Type: application/json 付きのきれいな HTTP/1.1 413 になったのは -d display_errors=0 の場合だけで、そのとき警告はサーバーのログに出力されました。php.ini-production には、すでに display_errors = Off が設定されています。
一時フォルダーと open_basedir
PHP は、スクリプトが実行される前に、すべてのアップロードを upload_tmp_dir またはシステムの一時フォルダーに一時ファイルとして書き込みます。open_basedir を使っていて、そのフォルダーを含めていないと、サイズにかかわらずすべてのアップロードが失敗します。ログには「open_basedir restriction in effect」と「unable to create a temporary file」が記録され、スクリプトには UPLOAD_ERR_NO_TMP_DIR が渡されます。サンプルはこれに 500 を返します。一時フォルダーを open_basedir に追加すると、35 項目の契約テストのチェックにすべて合格しました。Windows のコマンドラインで -d open_basedir=A;B と指定すると、; が ini のコメントの開始とみなされるため、何の警告もなく A だけが残ります。値は -d 'open_basedir="A;B"' のように引用符で囲んでください。
テストする
このガイドのサンプルプロジェクトを php -d upload_max_filesize=5M -d display_errors=0 -d log_errors=1 -S localhost:5201 router.php で起動し、サンプルの制限を超えるファイルと、post_max_size を超えるファイルを 1 つずつ送信します。
head -c 4500000 /dev/zero > big.jpg
curl -F "file=@big.jpg" http://localhost:5201/rte/upload
{"error":"Images can be at most 4,000,000 bytes."}
head -c 9000000 /dev/zero > huge.jpg
curl -F "file=@huge.jpg" http://localhost:5201/rte/upload
{"error":"The upload is larger than the server accepts (post_max_size)."}どちらのレスポンスも、JSON の本文を持つ HTTP 413 です。落とし穴を確認するには、-d display_errors=0 を付けずにサーバーを再起動し、もう一度 huge.jpg を送信します。php.ini のない PHP、または php.ini-development を使う PHP では、JSON の前に HTML の警告が出力されます。
ほかのプラットフォーム向けの同じガイド
JavaScript · Next.js · ASP.NET Core · MVC 5 · Web Forms