1. 今回の学習ゴールと練習課題(仕様)

あなたは小さなコミュニティサイトの開発を任されました。会員がプロフィール画像(アバター)をアップロードできるフォームを作ってください。ディレクターからの要件は次のとおりです。

練習課題の仕様

期待される実行結果(出力例)

// 正常な PNG(640×480)をアップロードした場合
アップロード成功: 9f2c41d07ab3e5f6182c9d0e4a7b3c51.png (640×480)

// 何も選択せずに送信した場合
ファイルが選択されていません。

// 中身がテキストなのに拡張子だけ .jpg にしたファイルの場合
JPEG / PNG / WebP 形式の画像のみアップロードできます。

// 3MB の画像を送信した場合
ファイルサイズは2MB以下にしてください。

2. 初心者がハマるNGコード(Before)と解説

まずは、ネットの古い記事などで今でもよく見かける「動くけど危険な落とし穴だらけ」のコードです。

<?php
// upload.php(NG例:絶対に本番で使わないこと)
$name = $_FILES['avatar']['name'];
$type = $_FILES['avatar']['type'];

if ($type === 'image/jpeg' || $type === 'image/png') {
    move_uploaded_file($_FILES['avatar']['tmp_name'], 'uploads/' . $name);
    echo 'アップロード完了: ' . $name;
}

一見シンプルに動きますが、セキュリティ上の問題が山積みです。

NGポイント①:$_FILES['avatar']['type'] を信じている

$_FILES['...']['type'] はブラウザ(クライアント)がリクエストヘッダー(Content-Type)で自己申告した値に過ぎません。curlコマンドや開発者ツールを使えば、中身が何であれ自由に偽装できます。たとえば中身が <?php echo "I am a text script, not an image!"; ?> というテキストのファイルを avatar.php という名前で、Content-Type: image/jpeg と偽装して送信すると、このチェックは完全に素通りしてしまいます。

NGポイント②:ユーザー申告のファイル名でそのまま保存している

上の例では uploads/avatar.php としてそのまま保存されてしまいます。もし uploads/ がWeb公開ディレクトリ配下にある場合、ブラウザから直接アクセスされると、サーバー設定次第でPHPスクリプトとして実行されてしまう(WebShell・任意コード実行)という致命的な脆弱性に直結します。さらに、同名ファイルによる上書き事故や、日本語・記号ファイル名による文字化け、Apache等の古い設定環境における二重拡張子問題(例: avatar.php.jpg がPHPとして実行される設定ミス)なども引き起こします。

NGポイント③:エラーコードの判定がなく、画面出力も未エスケープ

ユーザーがファイルを選択せずに送信ボタンを押した場合、$_FILES['avatar'] のキー構造によっては Warning: Undefined array key が発生します。また、php.ini のアップロード上限を超えた際のエラーコード(UPLOAD_ERR_INI_SIZE など)も一切ハンドリングされていません。さらに、ファイル名に <script>alert(1)</script>.png のような細工がされていた場合、そのまま echo しているため XSS(クロスサイトスクリプティング) が成立します。

3. コピペで即動く模範解答コード(After)

PHP 8.3 / 8.4 で動作確認できる完全版です。upload.php として保存し、php -S localhost:8000 で起動して http://localhost:8000/upload.php にアクセスしてください(fileinfo 拡張が有効である必要があります。多くの環境では標準で有効です)。

<?php
declare(strict_types=1);

const MAX_FILE_SIZE = 2 * 1024 * 1024; // 2MB
const MAX_DIMENSION = 4000;            // 縦横の最大ピクセル数
const UPLOAD_DIR    = __DIR__ . '/uploads';
const ALLOWED_MIME_TO_EXT = [
    'image/jpeg' => 'jpg',
    'image/png'  => 'png',
    'image/webp' => 'webp',
];

function h(string $value): string
{
    return htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML5, 'UTF-8');
}

/**
 * アップロードされた1ファイルを検証して保存する
 *
 * @param array<string, mixed> $file $_FILES['avatar'] の中身
 * @return array{ok: bool, message: string}
 */
function handleUpload(array $file): array
{
    // 1. 構造と型のチェック(未送信や複数ファイル送信、不正な配列構造を弾く)
    if (
        !isset($file['error'], $file['tmp_name'], $file['size'])
        || !is_int($file['error'])
        || !is_string($file['tmp_name'])
        || !is_int($file['size'])
    ) {
        return ['ok' => false, 'message' => '不正なリクエストです。'];
    }

    // 2. PHPのエラーコードを厳格に判定
    $errorMessage = match ($file['error']) {
        UPLOAD_ERR_OK                             => null,
        UPLOAD_ERR_INI_SIZE, UPLOAD_ERR_FORM_SIZE => 'ファイルサイズは2MB以下にしてください。',
        UPLOAD_ERR_NO_FILE                        => 'ファイルが選択されていません。',
        UPLOAD_ERR_PARTIAL                        => 'アップロードが途中で中断されました。',
        default                                   => 'サーバーエラーが発生しました。時間をおいて再度お試しください。',
    };
    if ($errorMessage !== null) {
        if ($file['error'] !== UPLOAD_ERR_NO_FILE) {
            error_log(sprintf('[upload] error code=%d', $file['error']));
        }
        return ['ok' => false, 'message' => $errorMessage];
    }

    // 3. 本当にHTTP POSTでアップロードされた一時ファイルか検証
    if (!is_uploaded_file($file['tmp_name'])) {
        return ['ok' => false, 'message' => '不正なファイルです。'];
    }

    // 4. サイズ上限をサーバー側で再チェック
    if ($file['size'] > MAX_FILE_SIZE) {
        return ['ok' => false, 'message' => 'ファイルサイズは2MB以下にしてください。'];
    }

    // 5. ファイルの「中身(バイナリ)」からMIMEタイプを判定し、拡張子を決定
    $finfo = new finfo(FILEINFO_MIME_TYPE);
    $mime  = $finfo->file($file['tmp_name']);
    $extension = is_string($mime) ? (ALLOWED_MIME_TO_EXT[$mime] ?? null) : null;
    if ($extension === null) {
        return ['ok' => false, 'message' => 'JPEG / PNG / WebP 形式の画像のみアップロードできます。'];
    }

    // 6. 画像として正常に読み込めるか・寸法は許容範囲内か検証
    $imageInfo = @getimagesize($file['tmp_name']);
    if ($imageInfo === false) {
        return ['ok' => false, 'message' => '画像ファイルとして読み込めませんでした。'];
    }
    [$width, $height] = $imageInfo;
    if ($width > MAX_DIMENSION || $height > MAX_DIMENSION) {
        return ['ok' => false, 'message' => '画像の縦横は4000px以下にしてください。'];
    }

    // 7. 保存先ディレクトリの存在確認と作成
    if (!is_dir(UPLOAD_DIR) && !mkdir(UPLOAD_DIR, 0755, true) && !is_dir(UPLOAD_DIR)) {
        error_log('[upload] failed to create upload directory');
        return ['ok' => false, 'message' => 'サーバーエラーが発生しました。時間をおいて再度お試しください。'];
    }

    // 8. ファイル名はサーバー側で安全な乱数を生成(元のファイル名は一切使わない)
    $newName     = bin2hex(random_bytes(16)) . '.' . $extension;
    $destination = UPLOAD_DIR . '/' . $newName;

    if (!move_uploaded_file($file['tmp_name'], $destination)) {
        error_log('[upload] move_uploaded_file failed');
        return ['ok' => false, 'message' => 'サーバーエラーが発生しました。時間をおいて再度お試しください。'];
    }
    chmod($destination, 0644);

    return [
        'ok'      => true,
        'message' => sprintf('アップロード成功: %s (%d×%d)', $newName, $width, $height),
    ];
}

$result = null;
if (($_SERVER['REQUEST_METHOD'] ?? '') === 'POST') {
    // post_max_size 超過時は $_POST と $_FILES が共に空になる PHP の仕様への対策
    if (empty($_FILES) && empty($_POST) && (int)($_SERVER['CONTENT_LENGTH'] ?? 0) > 0) {
        $result = ['ok' => false, 'message' => 'ファイルサイズがサーバーの上限(post_max_size)を超えています。'];
    } else {
        $result = handleUpload($_FILES['avatar'] ?? []);
    }
}
?>
<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <title>アバター画像アップロード</title>
</head>
<body>
  <h1>アバター画像アップロード</h1>

  <?php if ($result !== null): ?>
    <p style="color: <?= $result['ok'] ? 'green' : 'red' ?>;">
      <?= h($result['message']) ?>
    </p>
  <?php endif; ?>

  <form method="post" enctype="multipart/form-data">
    <input type="hidden" name="MAX_FILE_SIZE" value="<?= MAX_FILE_SIZE ?>">
    <div>
      <label for="avatar">プロフィール画像(JPEG / PNG / WebP、2MB以下):</label>
      <input type="file" id="avatar" name="avatar" accept="image/jpeg,image/png,image/webp">
    </div>
    <div style="margin-top: 12px;">
      <button type="submit">アップロード</button>
    </div>
  </form>
</body>
</html>

4. 現場のプロ(kixking)による徹底コード解説

ステップ1:enctype="multipart/form-data" と MAX_FILE_SIZE

初心者の「送信したのに $_FILES が空っぽです!」というトラブルの原因No.1が enctype="multipart/form-data" の付け忘れです。通常のフォーム送信(application/x-www-form-urlencoded)ではバイナリファイルは送られません。また、HTML側の hidden で指定した MAX_FILE_SIZE や accept 属性は、あくまでユーザーがブラウザでファイルを選びやすくするための「UI補助(利便性)」に過ぎず、簡単に改ざん・回避できます。すべての妥当性検証は必ずサーバー側のPHPで実施するのがWeb開発の鉄則です。

ステップ2:厳格な型チェックと match 式によるエラー網羅

declare(strict_types=1); を宣言したモダンPHP環境では、攻撃者が配列構造を改ざんして送ってきた場合に TypeError でスクリプトが落ちないよう、冒頭で is_int($file['error'])、is_string($file['tmp_name'])、is_int($file['size']) といった型チェックを確実に通します。そして match 式(厳格比較 ===)を用いて、PHP組み込みのアップロードエラー定数(未選択の UPLOAD_ERR_NO_FILE、サイズ超過の UPLOAD_ERR_INI_SIZE / UPLOAD_ERR_FORM_SIZE 等)を漏れなくハンドリングしています。利用者に表示するメッセージと、サーバーのエラーログ(error_log)に記録する詳細情報を明確に分離しているのも現場品質のポイントです。

ステップ3:MIME判定は finfo で「ファイルの中身(マジックナンバー)」を検査

拡張子やブラウザの自己申告値ではなく、PHP組み込みの finfo(FILEINFO_MIME_TYPE) を使ってファイルの先頭バイト列(マジックナンバー)を解析します(例: PNGなら先頭バイトが \x89PNG、JPEGなら \xFF\xD8)。さらに @getimagesize() を呼んで画像ヘッダーの解像度情報(幅・高さ)を二重チェックし、4000pxを超えるような極端な巨大画像によるメモリ枯渇(DoSリスク)を未然に防ぎます。

ステップ4:拡張子とファイル名はサーバーが安全に決定する

ここが最も重要なセキュリティ防壁です。保存時の拡張子は、ユーザーが送ってきた avatar.php.jpg や avatar.png から取るのではなく、ステップ3で中身から判定したMIMEタイプを元に、定義済みの許可リスト(ALLOWED_MIME_TO_EXT)から jpg、png、webp のいずれかを強制的に割り当てます。ファイル名も暗号論的擬似乱数生成器(CSPRNG)である bin2hex(random_bytes(16)) を用いて推測困難な32文字のランダム英数字に変換します。これにより、パストラバーサル、二重拡張子、ファイル名上書き、文字化けの問題が根底から消滅します。

ステップ5:is_uploaded_file と move_uploaded_file

一時ファイルの移動には、単純な rename() や copy() ではなく、必ず move_uploaded_file() を使用します。この関数は、対象ファイルが「本当にHTTP POST経由でアップロードされた正規の一時ファイルであるか」を内部で安全確認してから移動を行ってくれます。保存完了後は chmod($destination, 0644); で実行権限を確実に剥奪しておきます。

💡 現場のシニアエンジニアからの重要Tips

  • Tips①:巨大送信で $_FILES が消滅する?「post_max_size の罠」
    ファイルサイズが php.ini の post_max_size(デフォルト8M〜16Mなど)を超えると、PHPはリクエストボディの解析を破棄し、$_POST も $_FILES も完全に空の配列([])にしてしまいます。そのため模範コードでは、リクエスト受信時に $_SERVER['CONTENT_LENGTH'] を監視し、post_max_size 超過時に「未選択」や「不正なリクエスト」と誤認されない親切なハンドリングを実装しています。
  • Tips②:なぜ SVG(image/svg+xml)を画像として許可しないのか?
    SVGはベクター画像として便利ですが、実体はXMLベースのテキストデータです。内部に <script> タグを記述できるため、ブラウザで直接表示された際にJavaScriptが実行され、Stored XSS(格納型クロスサイトスクリプティング)の温床になります。一般ユーザー投稿の画像フォームでは、JPEG / PNG / WebP に限定するのがWebセキュリティの標準プラクティスです。
  • Tips③:本番環境の保存先は「ドキュメントルート外」またはオブジェクトストレージ
    ローカル演習では便宜上 __DIR__ . '/uploads' に保存していますが、本番の商用Webサービスでは、アップロードされたファイルをWebサーバーの公開ディレクトリ(ドキュメントルート)の外に保存するか、AWS S3やCloudflare R2などのクラウドオブジェクトストレージに隔離保存するのが鉄則です。万が一の悪意あるスクリプト実行をWebサーバー層で物理的に遮断できます。

5. もう一歩進める!発展チャレンジ課題

模範解答が動いたら、実務で求められる次のステップにぜひ挑戦してみてください。

  1. GDで再エンコードしてEXIF(位置情報)を消去せよ:スマホで撮影したJPEG写真には、撮影日時やカメラ機種のほか、自宅の位置が特定されかねない「GPS緯度経度(EXIFデータ)」が含まれます。imagecreatefromstring で一度メモリ上に画像を描画し、imagejpeg や imagepng で新規保存し直すことで、メタデータを完全に削ぎ落とせます。
  2. 複数ファイルのアップロード(name="photos[]")に対応せよ:<input type="file" name="photos[]" multiple> にすると、PHPの $_FILES['photos'] の構造が ['name' => [...], 'tmp_name' => [...]] という独特の配列構造に変わります。これを1ファイルごとの配列に正規化・組み替えてからループ処理するユーティリティ関数を書いてみましょう(最大枚数の上限制限も追加)。
  3. 非公開保存+配信スクリプトを作れ:画像をWeb非公開ディレクトリ(/var/data/uploads/ 等)に保存し、download.php?id=... 経由で Content-Type や X-Content-Type-Options: nosniff ヘッダーを付与して readfile() で出力する安全な配信パイプラインを構築してください。
  4. CSRFトークン検証を導入せよ:セッションに保存したワンタイムトークン(bin2hex(random_bytes(32)))をフォームの hidden フィールドに埋め込み、送信時に hash_equals() で照合するCSRF対策を組み込んでみましょう。

まとめ

ファイルアップロードの安全性を担保する核心は、「クライアントから送られてきた情報($_FILES['...']['type'] や name)は一切信用しない」というゼロトラストの思想です。エラーコードの網羅判定、バイナリ中身からのMIME判定、サーバー側での推測困難なファイル名生成、そして move_uploaded_file による隔離保存。この基本を体に染み込ませておけば、どのような現場でも自信を持って安全なアップロード機能を設計・実装できます。ぜひ手元で、わざと拡張子を偽装したファイルを送信して弾かれる様子などを実験してみてください。