PHP での画像アップロードのサイズ制限

大きなアップロードを拒否できるレイヤーの図。ギャラリーダイアログ、Web サーバー、post_max_size、upload_max_filesize、upload.php 内の 4,000,000 バイトのチェック

大きな写真の「Upload failed」は、5 か所のどこからでも発生する可能性があります。PHP ではそのうち 2 か所が php.ini にあり、スクリプトが実行される前に作用します。このページでは、リクエストが通過する順に各レイヤーと、それぞれが返すレスポンスを紹介します。また、設定を変えない限り、きれいな 413 を HTML ページに変えてしまう PHP の動作についても説明します。

各レイヤーとその順序

  1. ギャラリーダイアログ。エディターのオプションで maxUploadFileSize: 4000000 を指定すると、ダイアログはそれより大きなファイルを送信前に拒否します。4.5 MB のファイルでは「big.jpg is 4.3 MB. The maximum allowed size is 3.8 MB.」と表示されました(ダイアログは 2 進数のメガバイトで計算します)。これは利便性のための機能で、サーバーはこれに頼ることはできません。
  2. Web サーバー。nginx のドキュメントでは、client_max_body_size の既定値は 1m とされているため、2 MB の写真は PHP に届く前に、nginx 独自の HTML の 413 ページで拒否されます。Apache で同じ役割を果たすのは LimitRequestBody です。サンプルの nginx.conf.example では client_max_body_size 5m; を設定しています。
  3. post_max_size(既定値は 8M)は、リクエストの本文全体を制限します。
  4. upload_max_filesize(既定値は 2M)は、ファイル 1 つごとのサイズを制限します。
  5. 独自のチェック。サンプルでは 4,000,000 バイトで、JSON の 413 を返します。

サンプルの制限が適用されるようにするには、それより前のすべてのレイヤーで 4,000,000 バイトより少し大きいサイズまで許可しておく必要があります。マルチパートのエンコードによって、ファイルの前後に数百バイトが加わるためです。

php.ini
upload_max_filesize = 5M
post_max_size = 8M
display_errors = Off
log_errors = On

スクリプトがそれらを区別する方法

PHP · rte/upload.php
// 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 レスポンス(9,000,204 バイトのアップロード、display_errors 有効)
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 つずつ送信します。

Shell
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

次に読む