ファイルアップロードの例外処理はこれぐらいしないと気が済まない

**【2021/10/15 追記】**
**この記事は更新が停止されています。現在では筆者の思想が変化している面もありますので，過去の記事として参考程度にご覧ください。**

# 脆弱性について

## 参考リンク

- [PHPにおけるファイルアップロードの脆弱性CVE-2011-2202](http://blog.tokumaru.org/2011/06/PHP-file-upload-bug-CVE-2011-2202.html)
- [PHP 5.4.1リリースのポイント](http://d.hatena.ne.jp/rui_hi/20120429/1335664113)

## 上記に対する補足説明

- PHP **5.4.1**以降
- PHP **5.3.11**以降

どちらかを満たしているならば，脆弱性は（今のところ）無い．どちらも満たしていないと，

- __*`$_FILES`*__ 変数の構造を崩す攻撃
- **`../`** をファイル名に含めて送信する攻撃 **(ディレクトリトラバーサル)**

の何れか，もしくは両方の脆弱性を所持していることになるので要注意．

脆弱性対策と注意事項
=================

## _`$_FILES`_ Corruption 対策<br />改竄されたフォームからの複数ファイル配列送信対策

脆弱性が修正された環境でも **改竄フォーム対策** も兼ねているのでこれは必須．

```php:BAD
!isset($_FILES['upfile'])
```

```php:GOOD
!isset($_FILES['upfile']['error']) || !is_int($_FILES['upfile']['error'])
```

## Directory Traversal 対策<br />空ファイル名対策

脆弱性が修正された環境でも **ファイル名を空にすることは可能** なので，このパラメータを使用せずに自前で一意なファイル名を生成するのが最善の策．もし使うにしてもファイル名として使用して問題ないフォーマットになっているかしっかり検証する．

```php:ファイルデータの重複を許さない(バイナリデータからハッシュ値を取る)
$name = sha1_file($_FILES['upfile']['tmp_name']);
```

```php:ファイルデータの重複を許す(乱数を使う)
while (is_file($name = bin2hex(openssl_random_pseudo_bytes(32))));
```

<ins>ユーザーがファイルを削除可能</ins> な場合には **重複を許す必要がある** ので注意．あるユーザーがファイルを削除したら他のユーザーのファイルも消えてしまった，なんてことがあってはいけない．

もしユーザーがアップロードしたファイル名をそのまま使用したい場合，正規表現を使用して以下のものを防がなければならない．

- 先頭または末尾が `.` であったり， `/` が含まれているもの．ファイル名として無効であったり，ディレクトリトラバーサルが可能なものであったりする．
- 実行可能な拡張子． `.php` に加え， `.cgi` `.py` `.rb` なども実行可能な環境があるので注意．
- `半角英数字` `.` `_` `-` 以外の文字．さまざまなエンコーディングが存在するためである．もしこれを許可したい場合， [mb_convert_encoding](http://www.php.net/manual/ja/function.mb-convert-encoding.php) 関数を用いてサーバー環境に合うよう変換しなければならないが，ここでの説明は割愛する．

```rb:これにマッチすれば安全，マッチしなければ危険と見なす
/\A(?!\.)[\w.-]++(?<!\.)(?<!\.php)(?<!\.cgi)(?<!\.py)(?<!\.rb)\z/i
```


[move_uploaded_file](http://www.php.net/manual/ja/function.move-uploaded-file.php) じゃなくて [rename](http://www.php.net/manual/ja/function.rename.php) じゃだめ？
-------------------

[rename](http://www.php.net/manual/ja/function.rename.php) 関数ではなく [copy](http://www.php.net/manual/ja/function.copy.php) 関数であれば問題ない．以下のリンクを参照して頂きたい．

- [is_uploaded_file() / move_uploaded_file() の必要性？](http://qiita.com/mpyw/items/db12ce86b15f3b0b3c19)


アップロード例外処理サンプル
========================

```php:テキストとして結果を出力する例
<?php
    
header('Content-Type: text/plain; charset=utf-8');
    
try {
    
    // 未定義である・複数ファイルである・$_FILES Corruption 攻撃を受けた
    // どれかに該当していれば不正なパラメータとして処理する
    if (!isset($_FILES['upfile']['error']) || !is_int($_FILES['upfile']['error'])) {
        throw new RuntimeException('パラメータが不正です');
    }

    // $_FILES['upfile']['error'] の値を確認
    switch ($_FILES['upfile']['error']) {
        case UPLOAD_ERR_OK: // OK
            break;
        case UPLOAD_ERR_NO_FILE:   // ファイル未選択
            throw new RuntimeException('ファイルが選択されていません');
        case UPLOAD_ERR_INI_SIZE:  // php.ini定義の最大サイズ超過
        case UPLOAD_ERR_FORM_SIZE: // フォーム定義の最大サイズ超過 (設定した場合のみ)
            throw new RuntimeException('ファイルサイズが大きすぎます');
        default:
            throw new RuntimeException('その他のエラーが発生しました');
    }

    // ここで定義するサイズ上限のオーバーチェック
    // (必要がある場合のみ)
    if ($_FILES['upfile']['size'] > 1000000) {
        throw new RuntimeException('ファイルサイズが大きすぎます');
    }
    
    // $_FILES['upfile']['mime']の値はブラウザ側で偽装可能なので
    // MIMEタイプに対応する拡張子を自前で取得する
    if (!$ext = array_search(
        mime_content_type($_FILES['upfile']['tmp_name']),
        array(
            'gif' => 'image/gif',
            'jpg' => 'image/jpeg',
            'png' => 'image/png',
        ),
        true
    )) {
        throw new RuntimeException('ファイル形式が不正です');
    }

    // ファイルデータからSHA-1ハッシュを取ってファイル名を決定し，保存する
    if (!move_uploaded_file(
        $_FILES['upfile']['tmp_name'],
        $path = sprintf('./uploads/%s.%s',
            sha1_file($_FILES['upfile']['tmp_name']),
            $ext
        )
    )) {
        throw new RuntimeException('ファイル保存時にエラーが発生しました');
    }
    
    // ファイルのパーミッションを確実に0644に設定する
    chmod($path, 0644);
    
    echo 'ファイルは正常にアップロードされました';

} catch (RuntimeException $e) {

    echo $e->getMessage();

}
```

PSR-7ベースの方法 (2016/07/24 追記)
===============

今からコードを書く場合，この方法が最も推奨される．これはZend Frameworkのコンポーネントだが，もしフレームワーク側で個別に提供されている場合はそういったものを利用しても構わない．しかし，（任意多次元の配列に対応できるのかなど）**それが信用に値するコードか**を自分自身で確認した上で実際に使うことが望まれる．

- **[PSR-7のHttp/Messageを使ったファイルアップロード処理](http://qiita.com/asaokamei/items/d9194076e11931f5f3c0)**

上記の内容に従った例をPHPマニュアルのユーザノートに投稿した．[リンク](http://php.net/manual/en/features.file-upload.post-method.php#119643)をこちらに記載しておく．

# 実装例 (:warning:少しコードが古めなので注意:warning:)

具体的にフォームを交えて実装した例を紹介する．この記事では **「リクエストを受ける専用のファイル」** であることを想定してコードを書いたが，以下の例はリクエストを受けるだけでなく **「ユーザーにフォーム入力させるファイル」** という役割を兼ねさせている．1ファイルで全ての処理を担当するということである．

## [画像アップロード処理サンプル集](http://qiita.com/mpyw/items/73ee77a9535cc65eff1e)

- 単純な画像アップロード
- 画像形式の変換
- 画像のリサイズ
- 複数ファイルのアップロード

## [PHP+MySQLで簡易画像アップローダ](http://qiita.com/mpyw/items/117ab6a88fd58d911c34)

(上のサンプル集に含めようと考えましたが，内容が多すぎる気がしたので記事を分けました)

- アップロードした画像の **画像データ** をMySQLに挿入する
- アップロードした画像の **サムネイルデータ** を同時に作成してMySQLに挿入する
- 取り出してきた **画像データ** を<ins>画像ファイルとして直接出力する</ins>
- 取り出してきた **サムネイルデータ** を<ins>データURIスキームを利用してHTML内に出力する</ins>
- Internet Explorer のみで起こり得る **画像XSS** を防止する

## [ディレクトリ作成を許可してファイルをアップロード](http://qiita.com/mpyw/items/695f443674aef8d349c3)

- ユーザーがアップロードしたファイルの名前 `$_FILES['upfile']['name']` をそのまま使用する
- ユーザーに保存するディレクトリを指定する権限を与え，存在しないものに関しては自動的に生成させる

## [CSVアップロードからのMySQLへのデータ挿入](http://qiita.com/mpyw/items/caa2568284b69d270f8b)

- CSVファイルをユーザーにアップロードさせて，それをそのままMySQLのテーブルにINSERTする