コンテンツにスキップ

PHPでファイルアップロードを実装する方法|バリデーション・保存・セキュリティ完全ガイド

カテゴリ:PHP・実装

PHPでファイルアップロード機能を実装する際、「とりあえず動く」コードを書くのは簡単ですが、セキュリティ・バリデーション・エラーハンドリングまで考慮した堅牢な実装は意外と奥が深いです。本記事では、$_FILES の構造から始まり、MIME タイプ検証・拡張子ホワイトリスト・安全な保存先・ファイル名サニタイズまで、本番環境で使えるレベルの実装方法を体系的に解説します。

$_FILES の構造を理解する

HTML フォームで enctype="multipart/form-data" を指定してファイルを送信すると、PHPは $_FILES スーパーグローバル変数にアップロード情報を格納します。フォームの name 属性が userfile の場合、$_FILES['userfile'] は以下の5つのキーを持つ連想配列になります。

// $_FILES の構造
[
    'name'     => 'photo.jpg',        // クライアント側のファイル名
    'type'     => 'image/jpeg',       // クライアントが申告するMIMEタイプ
    'tmp_name' => '/tmp/phpA1B2C3',   // サーバーの一時保存パス
    'error'    => 0,                  // エラーコード(0 = 成功)
    'size'     => 204800,             // ファイルサイズ(バイト)
]

重要な点として、type はクライアントが送信した値であり、偽造が可能です。MIMEタイプの検証には必ず finfo を使ってサーバー側で確認してください。

UPLOAD_ERR_* エラーコード一覧と対処法

$_FILES['userfile']['error'] には以下のいずれかの定数値が入ります。アップロード処理の最初にこの値を確認し、エラーを適切にハンドリングすることが重要です。

定数 意味 対処法
UPLOAD_ERR_OK 0 アップロード成功 処理を継続
UPLOAD_ERR_INI_SIZE 1 php.ini の upload_max_filesize を超過 php.ini の設定を見直す
UPLOAD_ERR_FORM_SIZE 2 HTMLフォームのMAX_FILE_SIZEを超過 フォームの設定を見直す
UPLOAD_ERR_PARTIAL 3 ファイルが途中までしか転送されなかった 再アップロードを促す
UPLOAD_ERR_NO_FILE 4 ファイルが選択されていない ファイル選択を促す
UPLOAD_ERR_NO_TMP_DIR 6 一時ディレクトリが存在しない サーバー設定を確認
UPLOAD_ERR_CANT_WRITE 7 ディスクへの書き込みに失敗 ディスク容量・パーミッション確認
UPLOAD_ERR_EXTENSION 8 PHP拡張によりアップロードが停止 拡張モジュールの設定を確認
function getUploadErrorMessage(int $errorCode): string
{
    return match ($errorCode) {
        UPLOAD_ERR_INI_SIZE   => 'ファイルサイズがサーバーの上限を超えています。',
        UPLOAD_ERR_FORM_SIZE  => 'ファイルサイズがフォームの上限を超えています。',
        UPLOAD_ERR_PARTIAL    => 'ファイルが完全にアップロードされませんでした。再度お試しください。',
        UPLOAD_ERR_NO_FILE    => 'ファイルが選択されていません。',
        UPLOAD_ERR_NO_TMP_DIR => 'サーバーエラー: 一時ディレクトリが見つかりません。',
        UPLOAD_ERR_CANT_WRITE => 'サーバーエラー: ファイルの書き込みに失敗しました。',
        UPLOAD_ERR_EXTENSION  => 'サーバーエラー: 拡張機能によりアップロードが拒否されました。',
        default               => '不明なエラーが発生しました。',
    };
}

MIMEタイプバリデーション(finfo使用)

クライアントが送信する $_FILES['userfile']['type'] は偽造できるため、サーバー側で実際のファイル内容からMIMEタイプを判定する必要があります。PHP の finfo_file()(または FileInfo クラス)を使います。

function validateMimeType(string $tmpPath, array $allowedMimes): bool
{
    $finfo = new finfo(FILEINFO_MIME_TYPE);
    $detectedMime = $finfo->file($tmpPath);

    return in_array($detectedMime, $allowedMimes, true);
}

// 使用例: 画像ファイルのみ許可
$allowedMimes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
if (!validateMimeType($_FILES['userfile']['tmp_name'], $allowedMimes)) {
    throw new RuntimeException('許可されていないファイル形式です。');
}

finfo はファイルの先頭バイト(マジックナンバー)を読んでMIMEタイプを判定するため、拡張子を偽装した攻撃にも対応できます。ただし、テキスト系ファイル(CSV、JSONなど)は text/plain と判定されることがあるため、拡張子チェックと組み合わせることを推奨します。

拡張子ホワイトリスト

MIMEタイプ検証と合わせて、ファイル拡張子のホワイトリストチェックも実施しましょう。ブラックリスト(.php、.exe などを禁止)よりもホワイトリスト(許可するものだけを列挙)のほうが安全です。

function validateExtension(string $originalName, array $allowedExtensions): bool
{
    // pathinfo() で拡張子を取得し、小文字に正規化
    $extension = strtolower(pathinfo($originalName, PATHINFO_EXTENSION));

    return in_array($extension, $allowedExtensions, true);
}

// 使用例
$allowedExtensions = ['jpg', 'jpeg', 'png', 'gif', 'webp'];
if (!validateExtension($_FILES['userfile']['name'], $allowedExtensions)) {
    throw new RuntimeException('許可されていない拡張子です。');
}

// MIMEタイプと拡張子の対応を明示的にマッピングする方法
$mimeToExtensions = [
    'image/jpeg' => ['jpg', 'jpeg'],
    'image/png'  => ['png'],
    'image/gif'  => ['gif'],
    'image/webp' => ['webp'],
];

$finfo = new finfo(FILEINFO_MIME_TYPE);
$detectedMime = $finfo->file($_FILES['userfile']['tmp_name']);
$extension = strtolower(pathinfo($_FILES['userfile']['name'], PATHINFO_EXTENSION));

if (
    !isset($mimeToExtensions[$detectedMime]) ||
    !in_array($extension, $mimeToExtensions[$detectedMime], true)
) {
    throw new RuntimeException('ファイル形式と拡張子が一致しません。');
}

ファイルサイズ制限のコード例

PHPレベルでのサイズチェックは $_FILES['userfile']['size'] で行います。ただし、php.ini の upload_max_filesize を超えるファイルはそもそも $_FILES に届かず、UPLOAD_ERR_INI_SIZE エラーになります。そのため、エラーコードの確認を先に行うことが重要です。

const MAX_UPLOAD_SIZE = 10 * 1024 * 1024; // 10 MiB

function validateFileSize(int $fileSize, int $maxSize = MAX_UPLOAD_SIZE): bool
{
    return $fileSize > 0 && $fileSize <= $maxSize;
}

// 使用例
if (!validateFileSize($_FILES['userfile']['size'])) {
    $maxMiB = MAX_UPLOAD_SIZE / (1024 * 1024);
    throw new RuntimeException("ファイルサイズが上限({$maxMiB} MiB)を超えています。");
}

// アップロード処理を関数にまとめた例
function processUpload(array $file): array
{
    // 1. エラーコード確認
    if ($file['error'] !== UPLOAD_ERR_OK) {
        throw new RuntimeException(getUploadErrorMessage($file['error']));
    }

    // 2. サイズチェック
    if (!validateFileSize($file['size'])) {
        throw new RuntimeException('ファイルサイズが上限を超えています。');
    }

    // 3. MIMEタイプチェック
    $allowedMimes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
    if (!validateMimeType($file['tmp_name'], $allowedMimes)) {
        throw new RuntimeException('許可されていないファイル形式です。');
    }

    // 4. 拡張子チェック
    $allowedExts = ['jpg', 'jpeg', 'png', 'gif', 'webp'];
    if (!validateExtension($file['name'], $allowedExts)) {
        throw new RuntimeException('許可されていない拡張子です。');
    }

    return $file;
}

安全な保存先(public外)

アップロードされたファイルはドキュメントルート(public_html / public など)の外に保存するべきです。public ディレクトリ内に保存すると、URLで直接アクセスされたり、PHPファイルとして実行される危険があります。

// 推奨ディレクトリ構成
// /var/www/
//   ├── public/         ← ドキュメントルート(外部からアクセス可能)
//   │   └── index.php
//   └── storage/        ← public の外(外部から直接アクセス不可)
//       └── uploads/

// 定数で保存先を明示
define('UPLOAD_DIR', dirname(__DIR__) . '/storage/uploads/');

// ディレクトリが存在しない場合は作成
if (!is_dir(UPLOAD_DIR)) {
    mkdir(UPLOAD_DIR, 0755, true);
}

// ダウンロード提供時は PHP 経由でストリーミング
function serveFile(string $filename): void
{
    $filePath = UPLOAD_DIR . basename($filename);

    if (!file_exists($filePath)) {
        http_response_code(404);
        exit;
    }

    $finfo = new finfo(FILEINFO_MIME_TYPE);
    $mimeType = $finfo->file($filePath);

    header('Content-Type: ' . $mimeType);
    header('Content-Length: ' . filesize($filePath));
    header('Content-Disposition: attachment; filename="' . basename($filename) . '"');
    readfile($filePath);
    exit;
}

ファイル名のサニタイズ(uniqid使用)

クライアントが送信したファイル名をそのまま使用するのは危険です。ディレクトリトラバーサル(../../etc/passwd のようなパス)や、特殊文字を含むファイル名によるOSコマンドインジェクションのリスクがあります。uniqid() を使ってユニークなファイル名を生成し、元のファイル名は別途データベースなどに記録する方法が安全です。

function generateSafeFilename(string $originalName): string
{
    // 拡張子のみ元のファイルから引き継ぐ
    $extension = strtolower(pathinfo($originalName, PATHINFO_EXTENSION));

    // uniqid() + mt_rand() でユニークな名前を生成
    // more_entropy=true で精度を上げる
    $uniqueName = uniqid('upload_', true);

    // さらにランダム性を加える場合は bin2hex(random_bytes(8)) を使う
    // $uniqueName = bin2hex(random_bytes(16));

    return $uniqueName . '.' . $extension;
}

// 使用例
$safeFilename = generateSafeFilename($_FILES['userfile']['name']);
$savePath = UPLOAD_DIR . $safeFilename;

// is_uploaded_file() で正規のアップロードか確認してから移動
if (!is_uploaded_file($_FILES['userfile']['tmp_name'])) {
    throw new RuntimeException('不正なファイルアップロードを検出しました。');
}

if (!move_uploaded_file($_FILES['userfile']['tmp_name'], $savePath)) {
    throw new RuntimeException('ファイルの保存に失敗しました。');
}

// 元のファイル名、保存名、MIMEタイプをDBに記録
// db_insert(['original_name' => $_FILES['userfile']['name'], 'saved_name' => $safeFilename, ...]);

php.ini 設定(upload_max_filesize / post_max_size)

PHPでファイルアップロードを受け付けるには、php.ini(または .htaccess / php.ini ローカル設定)を適切に設定する必要があります。重要なのは upload_max_filesize だけでなく post_max_size も合わせて変更する点です。

; php.ini の設定
; ファイルアップロードを有効化
file_uploads = On

; 1ファイルあたりの上限(M = MiB 単位)
upload_max_filesize = 20M

; POSTリクエスト全体の上限
; upload_max_filesize より大きく設定する(フォームデータのオーバーヘッド分)
post_max_size = 25M

; 最大実行時間(大きなファイルのアップロードに対応)
max_execution_time = 300

; 最大入力時間(アップロードの読み込み時間)
max_input_time = 300

; メモリ上限(post_max_size より大きくする)
memory_limit = 128M

設定値の反映は phpinfo()ini_get() で確認できます。

// 現在の設定値を確認
echo ini_get('upload_max_filesize');  // 例: "20M"
echo ini_get('post_max_size');        // 例: "25M"

// バイト単位に変換するユーティリティ
function convertToBytes(string $value): int
{
    $value = trim($value);
    $last = strtolower($value[-1]);
    $num = (int) $value;

    return match ($last) {
        'g' => $num * 1024 * 1024 * 1024,
        'm' => $num * 1024 * 1024,
        'k' => $num * 1024,
        default => $num,
    };
}

まとめ:安全なアップロード処理のチェックリスト

  • UPLOAD_ERR_* でエラーコードを確認している
  • finfo でサーバー側からMIMEタイプを判定している
  • ホワイトリスト方式で拡張子を検証している
  • $_FILES['userfile']['size'] でファイルサイズを確認している
  • 保存先は public ディレクトリの外に設定している
  • is_uploaded_file() で正規アップロードを確認している
  • move_uploaded_file() でファイルを移動している
  • uniqid() または random_bytes() でファイル名を生成している
  • php.ini の upload_max_filesizepost_max_size を適切に設定している

この記事で使えるテストファイル

よくある質問

PHPのアップロードファイルサイズ上限のデフォルト値は?

php.iniのupload_max_filesizeのデフォルトは2MBです。post_max_sizeも8MBに設定されています。

PHPで複数ファイルを同時にアップロードするには?

inputタグにmultiple属性を付け、name属性を配列形式(files[])にします。$_FILES['files']で配列として受け取れます。

PHPのファイルアップロードでセキュリティ上注意すべき点は?

拡張子のホワイトリスト検証、finfo_file()でのMIMEタイプ確認、アップロード先をドキュメントルート外にすることが重要です。

📚 関連記事

PNG vs WebP vs AVIF|画像フォーマットの選び方と変換方法

PNG / JPEG / WebP / AVIF の特徴・用途・ブラウザ対応状況を比較。picture 要素での出し分け、DevLab の画像フォーマット変換ツールの使い方も解説。

2026-04-18

Whois でドメイン情報を調べる方法|有効期限・ネームサーバー・登録者

Whois でわかること (登録者・有効期限・レジストラ・NS)、GDPR によるプライバシー保護の影響、ドメイン管理の実務的な使い方を解説。

2026-04-18

HTTP ステータスコード完全ガイド|よくあるエラーの原因と対処法

開発者が頻出する HTTP ステータスコード (200/301/302/400/401/403/404/413/422/429/500/502/503/504) の意味・原因・対処法を解説。301 vs 302 の SEO 影響、400 vs 422 の使い分けも。

2026-04-18

cURL コマンドを JavaScript fetch・Python requests に変換する方法|DevTools 連携

Chrome DevTools の Copy as cURL を fetch / axios / Python requests / PHP cURL / Go net/http に変換する手順を解説。主要 cURL オプション (-X / -H / -d / -F / -u / -b / -L) の変換パターン、認証トークンの扱い、注意点まで。

2026-04-16

Cookie のセキュリティフラグ完全ガイド|Secure / HttpOnly / SameSite / __Host-

Cookie のセキュリティ属性 Secure / HttpOnly / SameSite (Strict/Lax/None) / __Host- __Secure- プレフィックス / 4096 バイト制限を解説。CSRF / XSS / セッションハイジャック対策と、Laravel / Express の実装例。

2026-04-16

JWT のセキュリティベストプラクティス|alg none 攻撃 / 有効期限 / 署名検証

JWT (JSON Web Token) の代表的な脆弱性 6 種類 (alg none 攻撃 / 鍵混同 / 無期限トークン / payload への機密情報 / 失効不可 / 弱いシークレット) と対策。リフレッシュトークンパターン、失効リスト、HttpOnly Cookie 格納まで。

2026-04-16