幻想郷はなんでも受け入れるとか言ってたのが八雲紫ですが、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経由でうっかり情報漏洩という悲劇はさんざんです。



