WordPressのファイル投稿を制御しよう

WordPressのファイル投稿を制御しよう

幻想郷はなんでも受け入れるとか言ってたのが八雲紫ですが、WordPressは制限付きで受け入れるのが私の考えです。当たり前の話だと思いますが、受け入れすぎはロクなことになりません。特にウェブアプリケーション層でフリーダムだと、そのうち事故ります。極端な話、WordPressで動画配信する必要があるのか?という問いになります。前置きはともかく、規制しましょう。EXIF削除とファイルチェックです

ファイルチェックの重要性

一応WordPressの標準機能で、PHPやらEXEやらの直接投稿は制限されているものの、個人的にはさらに厳格にすべきと考えています。次のようなファイルがアップロードされてしまうのはあまりよろしくありません。ファイル名「php.txt」

<?php phpinfo();

もちろんこんなファイルがアップロードされたところで、ブラウザには「<?php phpinfo();」と表示されて終わるのですが、本来こんなファイルをアップロードするのは攻撃者ぐらいなものか、実験者でしょう。それに、脆弱なプラグインはWordPressの関数使いながらも迂回する可能性がゼロではありません。また、WordPressで20MBも30MBもファイルをアップロードするのは意味不明です。例えば動画ファイルならYouTubeなりニコニコなり、あるいはbilibiliでもFC2動画にでも上げるべきです(ストレージがカツカツになります)。

media file limiter

安直な名前ですが、要するに変な拡張子のファイルを強制制限する+mimeタイプ検証するプラグインです。OWASP掲載で超有名なのは「.php%00.jpg」です。上記の例だと、「test.php%00.txt」としてアップロードすると、ヌルバイトが切り捨てられます。つまり「test.php」がアップロードされる可能性が皆無ではありません。非常によろしくありません。制限しましょう。

/**
 * WordPressの最大アップロードサイズをプラグインの設定値に制限する
 * @param int $max_size PHP/WordPressで許可されている最大アップロードサイズ(バイト)。
 * @return int 制限された最大アップロードサイズ(バイト)。
 */
add_filter('upload_size_limit', 'mfl_set_upload_size_limit');

function mfl_set_upload_size_limit($max_size) {
    // ファイルをアップロードする権限を持つユーザーにのみ適用
    if ( ! current_user_can('upload_files') ) {
        return $max_size;
    }
    
    $option_name = MFL_OPTION_PREFIX . 'max_upload_size';
    // 設定された最大サイズ (MB) を安全な整数として取得
    $limit_mb = (int) get_option($option_name, 8);
    
    // 設定値が0以下の場合はスキップ
    if ($limit_mb <= 0) {
        return $max_size;
    }

    $custom_limit_bytes = $limit_mb * 1024 * 1024; // MBをバイトに変換

    // PHP/WPの制限値とカスタム制限値のうち、小さい方を返す
    return min($max_size, $custom_limit_bytes);
}

/**
 * wp_handle_upload_prefilterフックに登録し、早期にアップロードチェックを実行する
 * 優先度 1: ほとんどの処理よりも早く介入するため
 */
add_filter('wp_handle_upload_prefilter', 'mfl_check_upload_pre_filter', 1);

/**
 * アップロードされたファイルに関するカスタムチェックを実行する
 * @param array $file アップロードされるファイルに関するデータ。
 * @return array 変更されたファイルデータ。エラーがあれば 'error' キーが含まれる。
 */
function mfl_check_upload_pre_filter($file) {
    // 既にエラーがある場合はスキップ
    if (isset($file['error']) && $file['error'] !== false) {
        return $file;
    }
    
    // 0. MIMEタイプ厳格チェック (finfoを使用)
    $file = mfl_strict_mime_type_check($file);
    if (isset($file['error'])) {
        return $file;
    }

    // 1. 危険な拡張子のチェック
    $file = mfl_block_forbidden_extensions($file);
    if (isset($file['error'])) {
        return $file;
    }

    // 2. ファイルサイズのチェック (拡張子チェック後に実行)
    $file = mfl_limit_file_size($file);
    if (isset($file['error'])) {
        return $file;
    }
    
    return $file;
}

/**
 * ファイルのMIMEタイプを厳格に検証し、許可されたタイプのみを許可する (セキュリティ強化)。
 *
 * @param array $file アップロードされるファイルに関するデータ。
 * @return array 変更されたファイルデータ。エラーがあれば 'error' キーが含まれる。
 */
function mfl_strict_mime_type_check($file) {
    // finfo拡張機能がない場合はスキップ(PHP 5.3以降では通常有効)
    if ( ! extension_loaded('fileinfo') || ! isset($file['tmp_name']) || ! is_uploaded_file($file['tmp_name']) ) {
        return $file;
    }

    $finfo = finfo_open(FILEINFO_MIME_TYPE);
    
    if ( ! $finfo ) {
        // finfo_openが失敗した場合もスキップ
        return $file;
    }

    $tmp_name = $file['tmp_name'];
    $mime = finfo_file($finfo, $tmp_name);
    finfo_close($finfo);

    // 許可されたカスタムMIMEタイプを取得し、配列に変換
    $allowed_mimes_str = get_option(MFL_OPTION_PREFIX . 'allowed_mime_types', '');
    $custom_allowed_mimes = array_map('trim', explode(',', strtolower($allowed_mimes_str)));
    $custom_allowed_mimes = array_filter($custom_allowed_mimes);


    // 許可条件: 
    // 1. 標準カテゴリ(image, video, audio, text)に属するか、
    // 2. カスタム設定リストに含まれる
    if (preg_match('/^(image|video|audio|text)\//', $mime) || in_array($mime, $custom_allowed_mimes, true)) {
        return $file;
    }

    // 上記のいずれにも該当しない場合はブロック
    $error_message = sprintf(
        /* translators: %s: Detected MIME type */
        esc_html__('検出されたMIMEタイプ「%s」はセキュリティ上の理由により許可されていません。', 'media-file-limiter'),
        esc_html($mime)
    );
    $file['error'] = $error_message;
    return $file;
}

/**
 * 危険なファイル拡張子をブロックする (より厳格なチェックを適用)
 *
 * NOTE: この関数内で拡張子は**小文字に正規化**されるため、大文字・小文字を使った拡張子偽装(例: 'JpG')は確実にブロックされます。
 *
 * @param array $file アップロードされるファイルに関するデータ。
 * @return array 変更されたファイルデータ。エラーがあれば 'error' キーが含まれる。
 */
function mfl_block_forbidden_extensions($file) {
    $option_name = MFL_OPTION_PREFIX . 'forbidden_extensions';
    // データベースからサニタイズされた禁止リスト文字列を取得
    $forbidden_list_str = get_option($option_name, 'exe, php, phtml, html, shtml, js');

    // 禁止リストを処理: 小文字化し、トリミングし、空の要素を削除
    $forbidden_list_array = array_map('trim', explode(',', $forbidden_list_str));
    $forbidden_list_array = array_map('strtolower', $forbidden_list_array);
    $forbidden_list_array = array_filter($forbidden_list_array);

    // ファイル拡張子の取得と小文字化 (ここで大文字・小文字の偽装を防ぐ)
    $file_info = wp_check_filetype( $file['name'] );
    $ext = isset( $file_info['ext'] ) ? strtolower( $file_info['ext'] ) : '';

    // wp_check_filetype()が拡張子を認識できない場合のフォールバック
    if ( empty( $ext ) ) {
        $ext = pathinfo( $file['name'], PATHINFO_EXTENSION );
        $ext = strtolower( $ext ); // ここでも確実に小文字化
    }

    if ( in_array($ext, $forbidden_list_array, true) ) {
        // エラーメッセージを作成
        $error_message = sprintf(
            /* translators: %s: The forbidden file extension, e.g., 'exe' */
            esc_html__('セキュリティ上の理由により、ファイル拡張子「.%s」のアップロードは許可されていません。', 'media-file-limiter'),
            esc_html($ext)
        );

        $file['error'] = $error_message;
        return $file;
    }
    
    return $file;
}

/**
 * ファイルサイズを制限する
 *
 * @param array $file アップロードされるファイルに関するデータ。
 * @return array 変更されたファイルデータ。エラーがあれば 'error' キーが含まれる。
 */
function mfl_limit_file_size($file) {
    $option_name = MFL_OPTION_PREFIX . 'max_upload_size';
    // 設定された最大サイズ (MB) を安全な整数として取得
    $limit_mb = (int) get_option($option_name, 8);
    
    // 設定が無効な値(0以下)の場合はチェックをスキップ
    if ($limit_mb <= 0) {
        return $file;
    }

    // $file['size']が存在しない場合はfilesize()で取得を試みる
    $file_size_bytes = (int) ($file['size'] ?? 0);
    
    if ($file_size_bytes === 0 && isset($file['file']) && file_exists($file['file'])) {
        $file_size_bytes = filesize($file['file']);
    }

    $limit_bytes = $limit_mb * 1024 * 1024; // MBをバイトに変換

    if ($file_size_bytes > $limit_bytes) {
        // エラーメッセージを作成
        $error_message = sprintf(
            /* translators: 1: uploaded file size (e.g., 10.5MB), 2: maximum allowed size (e.g., 8MB) */
            esc_html__('ファイルサイズが制限を超えています。アップロードされたファイルサイズ: %1$s、許可されている最大サイズ: %2$s。', 'media-file-limiter'),
            esc_html(size_format($file_size_bytes)),
            esc_html(size_format($limit_bytes))
        );

        $file['error'] = $error_message;
        return $file;
    }

    return $file;
}

// wp_handle_uploadフックに登録し、ファイルがサーバーに保存された直後に再チェックを実行する
// 優先度 1: ほとんどの処理よりも早く介入するため
add_filter('wp_handle_upload', 'mfl_recheck_after_upload', 1, 2);

/**
 * ファイル保存直後にセキュリティチェックを再実行し、問題があれば削除する (最終防衛線)。
 *
 * エラーが検出された場合、ファイルを強制的に削除してからエラーを返す。
 *
 * @param array $file アップロード後にWordPressが返すファイルデータ (file, url, type, error)。
 * @param string $context アップロードコンテキスト。
 * @return array 変更されたファイルデータ。エラーがあれば 'error' キーが含まれる。
 */
function mfl_recheck_after_upload($file, $context) {
    // 既にエラーがある場合はスキップ
    if (isset($file['error']) && $file['error'] !== false) {
        return $file;
    }
    
    // 再チェックのために、既存のチェック関数が期待する配列形式に近い構造を作成
    $recheck_file = array(
        'name'     => basename($file['file']), 
        'file'     => $file['file'], 
        'error'    => 0,
        'size'     => 0, 
    );

    $error_detected = false;
    $error_message = '';

    // 1. 危険な拡張子の再チェック 
    $recheck_file = mfl_block_forbidden_extensions($recheck_file);
    if (isset($recheck_file['error']) && $recheck_file['error'] !== 0) {
        $error_detected = true;
        $error_message = $recheck_file['error'];
    }

    // 2. ファイルサイズの再チェック
    if (!$error_detected) {
        $recheck_file = mfl_limit_file_size($recheck_file);
        if (isset($recheck_file['error']) && $recheck_file['error'] !== 0) {
            $error_detected = true;
            $error_message = $recheck_file['error'];
        }
    }

    // エラーが検出された場合、ファイルを削除し、エラーメッセージを設定
    if ($error_detected) {
        // ファイルを強制削除 (wp_delete_file()は規約適合関数)
        if ( function_exists( 'wp_delete_file' ) && file_exists( $file['file'] ) ) {
            wp_delete_file( $file['file'] );
        } else {
            // wp_delete_fileが利用できない場合のフォールバック
            @unlink($file['file']); 
        }
        
        // エラーを返す
        $file['error'] = $error_message;
        return $file;
    }

    return $file;
}

仕組みはいたって簡単で、ファイルの容量を制限しつつ、変な拡張子のファイルを規制し、さらにMIME TYPEをチェックします。そのためファイル偽装や、「test.PhP」などといった抜け道を強制的に潰しています。最後の処理でdelete使っているので、PCP上はエラーになってしまうのですが、最終的に強制削除をするためにあえて残しています。

Exifも消そう

Exifを削除するプラグインは以前に作りましたが、ご存じのとおり画像には様々なExif(メタデータ)があります。例えばこのネコの写真ですが、調べてみるとこのようなExif情報が残っています。GPSと紐づけしていたら撮影場所までバレます。ネコもビックリです。

これが自動編集前のExif情報です。ここに自宅のGPS(位置情報)が入っていれば、全世界に自宅丸見えです。ネコどころか住民も逃げ出します。なのでいっそう、これも自動削除しましょう。

function mer_strip_uploaded_image_exif($file) {
    $option_enabled = MER_OPTION_PREFIX . 'enabled';

    // 1. 設定を取得
    $is_enabled = (int) get_option($option_enabled, 0);

    // 2. 設定がオフの場合、またはアップロードにエラーがある場合はスキップ
    if (!$is_enabled || (isset($file['error']) && $file['error'] !== false)) {
        return $file;
    }

    $type = isset($file['type']) ? $file['type'] : '';
    $file_path = isset($file['file']) ? $file['file'] : '';
    
    // GD拡張機能が利用可能か確認
    if (!extension_loaded('gd')) {
        return $file;
    }

    $image = false;
    $success = false;

    try {
        switch ($type) {
            case 'image/jpeg':
            case 'image/jpg':
            case 'image/pjpeg':
                if (function_exists('imagecreatefromjpeg')) {
                    $image = imagecreatefromjpeg($file_path);
                    if ($image !== false) {
                        // JPEG: 品質100で再保存し、画質の劣化を最小限に抑える
                        $success = imagejpeg($image, $file_path, 100); 
                    }
                }
                break;

            case 'image/png':
                if (function_exists('imagecreatefrompng')) {
                    $image = imagecreatefrompng($file_path);
                    if ($image !== false) {
                        // PNG: 圧縮レベル9 (最も高い圧縮率、品質劣化は少ない) で再保存
                        $success = imagepng($image, $file_path, 9); 
                    }
                }
                break;

            case 'image/gif':
                if (function_exists('imagecreatefromgif')) {
                    $image = imagecreatefromgif($file_path);
                    if ($image !== false) {
                        // GIF: 品質オプションなしで再保存
                        $success = imagegif($image, $file_path);
                    }
                }
                break;
            
            case 'image/webp':
                if (function_exists('imagecreatefromwebp')) {
                    $image = imagecreatefromwebp($file_path);
                    if ($image !== false) {
                        // WebP: 品質100で再保存
                        $success = imagewebp($image, $file_path, 100); 
                    }
                }
                break;
                
            default:
                // 上記以外の画像形式は処理をスキップ
                return $file;
        }

        // 画像リソースが作成された場合はメモリを解放
        if ($image !== false) {
            imagedestroy($image);
        }

    } catch (\Exception $e) {
        // アップロード自体は続行
    }
    // 再保存が成功しても失敗しても、ファイル自体は存在するため、$file を返す
    return $file;
}

入れておくべき理由

一応最優先でWordPressに介入しているので、プラグインがWordPressの内部を経由するのであれば、どのような悪質なファイルもこれが制限します。身も蓋もないこというと、さらにはアップロードの容量も制限します。20MBも30MBもWordPressで配信する意味がわかりません(そんなに大きなファイルは外部のストレージサービスを使うべきです)。ので、制限すべきと考えた次第です。またExif経由でうっかり情報漏洩という悲劇はさんざんです。

ダウンロードはWordPress.orgからお願いしますEXIF削除はこちらからどうぞ

コメントを投稿する

メールアドレスは公開されませんのでご安心ください。 * が付いている欄は必須項目となります。

内容に問題なければ、下記の「コメントを送信する」ボタンを押してください。

ページの先頭