WordPress | メディアライブラリで SVG を安全に許可する:カスタム権限対応 MU プラグイン完全ガイド

SVG はロゴやアイコンに最適ですが、スクリプト実行や外部参照が可能なため、無制限に許可するとセキュリティリスクがあります。本記事では、だれに SVG を許可するかをコードで制御しつつ、アップロード前に最低限のサニタイズを行う MU プラグイン(必ず自動読み込み)を作り、実運用レベルで安全に扱う方法を解説します。テーマ側に入れる簡易版(functions.php)も併記します。


この記事で実現すること

  • SVG を 指定の権限(capability)保持者だけに許可
  • アップロード時に 偽装(拡張子/MIME)を検知
  • アップロード直前に 最低限のサニタイズ<script>on* 属性等の除去)
  • 管理画面で SVG サムネイルの見た目を調整
  • コード内の配列を変えるだけで 許可ロールを後から変更可能

ファイル構成(例)

wp-content/
└─ mu-plugins/
   └─ allow-svg-safely-cap.php   // 本記事の完成版プラグイン

MU プラグインは有効化操作が不要で、設置だけで読み込まれます。誤って無効化されにくいのが利点です。


完成コード(MU プラグイン版・推奨)

wp-content/mu-plugins/allow-svg-safely-cap.php

<?php
/**
 * Plugin Name: Allow SVG Safely with Custom Capability (MU)
 * Description: SVGアップロードを安全に許可。誰に許可するかをコードで指定。偽装検知と最低限のサニタイズを実施。
 * Version: 1.1.0
 * Author: Your Team
 *
 * ポイント
 * - 許可/不許可の判定を custom capability(デフォルト: upload_svg)に委譲
 * - 初回アクセス時に、指定ロールへ自動的にcapを付与
 * - MUプラグインのため常時有効化
 * - サニタイズは最低限。100%の無害化は保証しないため、必要に応じて専用ライブラリ導入を推奨
 */

/* ============================================================
 * 0) 設定:cap名と付与対象ロール(必要に応じて編集)
 * ============================================================ */
if (!defined('SVG_UPLOAD_CAP')) {
    define('SVG_UPLOAD_CAP', 'upload_svg'); // SVG許可用cap(任意名OK)
}

if (!defined('SVG_UPLOAD_ROLES')) {
    // このcapを付与するロール(必要に応じて増減)
    define('SVG_UPLOAD_ROLES', serialize([
        'administrator',
        'editor',
        'author',
        // 'contributor',
        // 'shop_manager',
    ]));
}

/* ============================================================
 * 1) 初回のみcap付与(mu-pluginsにはactivationフックが無いためオプションで制御)
 * ============================================================ */
add_action('init', function () {
    if (get_site_option('allow_svg_cap_granted') === 'done') return;

    $roles = @unserialize(SVG_UPLOAD_ROLES);
    if (!is_array($roles)) $roles = [];

    foreach ($roles as $roleName) {
        $role = get_role($roleName);
        if ($role && !$role->has_cap(SVG_UPLOAD_CAP)) {
            $role->add_cap(SVG_UPLOAD_CAP);
        }
    }
    update_site_option('allow_svg_cap_granted', 'done');
});

/* ============================================================
 * 2) .svg のMIME許可(cap保持者のみ)
 * ============================================================ */
add_filter('upload_mimes', function ($mimes) {
    if (current_user_can(SVG_UPLOAD_CAP)) {
        $mimes['svg'] = 'image/svg+xml';
    } else {
        unset($mimes['svg']);
    }
    return $mimes;
});

/* ============================================================
 * 3) 拡張子/MIMEの偽装検知(<svg の存在を先頭512KBから確認)
 * ============================================================ */
add_filter('wp_check_filetype_and_ext', function ($data, $file, $filename) {
    if (strtolower(pathinfo($filename, PATHINFO_EXTENSION)) !== 'svg') return $data;

    if (!current_user_can(SVG_UPLOAD_CAP)) {
        return ['ext' => false, 'type' => false, 'proper_filename' => false];
    }

    $snippet = @file_get_contents($file, false, null, 0, 1024 * 512);
    if ($snippet === false || stripos($snippet, '<svg') === false) {
        return ['ext' => false, 'type' => false, 'proper_filename' => false];
    }

    $data['ext']  = 'svg';
    $data['type'] = 'image/svg+xml';
    return $data;
}, 10, 3);

/* ============================================================
 * 4) アップロード前に最低限のサニタイズ
 *    - <script>タグ削除
 *    - on*属性(onload等)削除
 *    - href/xlink:href の javascript:/data:text/html を拒否
 *    - 外部エンティティ(XXE)無効化
 * ============================================================ */
add_filter('wp_handle_upload_prefilter', function ($file) {
    if (strtolower(pathinfo($file['name'], PATHINFO_EXTENSION)) !== 'svg') return $file;

    if (!current_user_can(SVG_UPLOAD_CAP)) {
        $file['error'] = 'このユーザーにはSVGのアップロードは許可されていません。';
        return $file;
    }

    $tmp = $file['tmp_name'];
    $raw = @file_get_contents($tmp);
    if ($raw === false || stripos($raw, '<svg') === false) {
        $file['error'] = '有効なSVGではありません。';
        return $file;
    }

    // 1) XML宣言/DOCTYPE除去(外部エンティティ対策)
    $clean = preg_replace('/^\s*<\?xml[^>]*\?>/i', '', $raw);
    $clean = preg_replace('/<!DOCTYPE[^>]*>/i', '', $clean);

    // 2) DOMで危険要素除去
    $dom = new DOMDocument();

    // PHP8以降では非推奨だが互換のため防御的に記述
    $prevDisable = function_exists('libxml_disable_entity_loader') ? libxml_disable_entity_loader(true) : null;
    libxml_use_internal_errors(true);

    if ($dom->loadXML($clean, LIBXML_NONET | LIBXML_NOENT | LIBXML_DTDLOAD | LIBXML_NOWARNING | LIBXML_NOERROR)) {
        // <script>削除
        while (true) {
            $scripts = $dom->getElementsByTagName('script');
            if ($scripts->length === 0) break;
            $scripts->item(0)->parentNode->removeChild($scripts->item(0));
        }
        // on*属性・危険URL除去
        $xpath = new DOMXPath($dom);
        foreach ($xpath->query('//@*') as $attr) {
            $name = strtolower($attr->name);
            if (strpos($name, 'on') === 0) {
                $attr->ownerElement->removeAttribute($attr->name);
                continue;
            }
            if ($name === 'href' || $name === 'xlink:href') {
                if (preg_match('/^\s*javascript:/i', $attr->value) || preg_match('/^\s*data\s*:\s*text\/html/i', $attr->value)) {
                    $attr->ownerElement->removeAttribute($attr->name);
                }
            }
        }
        $clean = $dom->saveXML();
    }

    libxml_clear_errors();
    libxml_use_internal_errors(false);
    if (function_exists('libxml_disable_entity_loader') && $prevDisable !== null) {
        libxml_disable_entity_loader($prevDisable);
    }

    // 3) 保険の正規表現
    $deny = [
        '/<script\b[^>]*>.*?<\/script>/is',
        '/on[a-z]+\s*=\s*"[^"]*"/i',
        "/on[a-z]+\s*=\s*'[^']*'/i",
        '/on[a-z]+\s*=\s*[^\s>]+/i',
        '/javascript\s*:/i',
        '/data\s*:\s*text\/html/i',
    ];
    $clean = preg_replace($deny, '', $clean);

    if (@file_put_contents($tmp, $clean) === false) {
        $file['error'] = 'SVGのサニタイズに失敗しました。';
    }
    return $file;
});

/* ============================================================
 * 5) 管理画面サムネイルの見た目調整(任意)
 * ============================================================ */
add_action('admin_head', function () {
    echo '<style>
    .attachment .thumbnail img[src$=".svg"],
    img[src$=".svg"].attachment-post-thumbnail,
    .media-icon img[src$=".svg"] { width:100%; height:auto; }
    </style>';
});

/* ============================================================
 * 6) 付与ロールの調整メモ
 * - SVG_UPLOAD_ROLES を変更しても「初回付与」しか走りません。
 * - 既に付与済みのcapの増減は一時コード or WP-CLIで実施してください。
 *
 * // 一時的にauthorからcapを剥奪したい例(1アクセスで実行→このコードは削除)
 * add_action('init', function () {
 *     if ($role = get_role('author')) {
 *         if ($role->has_cap(SVG_UPLOAD_CAP)) $role->remove_cap(SVG_UPLOAD_CAP);
 *     }
 * });
 */

簡易版(functions.php 版)

テーマ側で試す場合の簡易版です。テーマ切替でcap付与が繰り返される可能性がある点に注意してください。

<?php
// 0) 設定
if (!defined('SVG_UPLOAD_CAP')) define('SVG_UPLOAD_CAP', 'upload_svg');
if (!defined('SVG_UPLOAD_ROLES')) define('SVG_UPLOAD_ROLES', serialize(['administrator','editor','author']));

// 1) ロールにcap付与(テーマのライフサイクルに依存)
add_action('after_setup_theme', function () {
    $roles = @unserialize(SVG_UPLOAD_ROLES);
    if (!is_array($roles)) $roles = [];
    foreach ($roles as $roleName) {
        $role = get_role($roleName);
        if ($role && !$role->has_cap(SVG_UPLOAD_CAP)) $role->add_cap(SVG_UPLOAD_CAP);
    }
});

// 2) .svg 許可(cap保持者のみ)
add_filter('upload_mimes', function ($mimes) {
    if (current_user_can(SVG_UPLOAD_CAP)) $mimes['svg'] = 'image/svg+xml';
    else unset($mimes['svg']);
    return $mimes;
});

// 3) 偽装検知
add_filter('wp_check_filetype_and_ext', function ($data, $file, $filename) {
    if (strtolower(pathinfo($filename, PATHINFO_EXTENSION)) !== 'svg') return $data;
    if (!current_user_can(SVG_UPLOAD_CAP)) return ['ext'=>false,'type'=>false,'proper_filename'=>false];
    $snippet = @file_get_contents($file, false, null, 0, 1024*512);
    if ($snippet === false || stripos($snippet, '<svg') === false) return ['ext'=>false,'type'=>false,'proper_filename'=>false];
    $data['ext']  = 'svg';
    $data['type'] = 'image/svg+xml';
    return $data;
}, 10, 3);

// 4) 簡易サニタイズ
add_filter('wp_handle_upload_prefilter', function ($file) {
    if (strtolower(pathinfo($file['name'], PATHINFO_EXTENSION)) !== 'svg') return $file;
    if (!current_user_can(SVG_UPLOAD_CAP)) { $file['error']='このユーザーにはSVGのアップロードは許可されていません。'; return $file; }
    $tmp = $file['tmp_name'];
    $raw = @file_get_contents($tmp);
    if ($raw === false || stripos($raw, '<svg') === false) { $file['error']='有効なSVGではありません。'; return $file; }

    $clean = preg_replace('/^\s*<\?xml[^>]*\?>/i', '', $raw);
    $clean = preg_replace('/<!DOCTYPE[^>]*>/i', '', $clean);

    $dom = new DOMDocument();
    $prevDisable = function_exists('libxml_disable_entity_loader') ? libxml_disable_entity_loader(true) : null;
    libxml_use_internal_errors(true);
    if ($dom->loadXML($clean, LIBXML_NONET | LIBXML_NOENT | LIBXML_DTDLOAD | LIBXML_NOWARNING | LIBXML_NOERROR)) {
        while (true) {
            $scripts = $dom->getElementsByTagName('script');
            if ($scripts->length === 0) break;
            $scripts->item(0)->parentNode->removeChild($scripts->item(0));
        }
        $xpath = new DOMXPath($dom);
        foreach ($xpath->query('//@*') as $attr) {
            $n = strtolower($attr->name);
            if (strpos($n, 'on') === 0) $attr->ownerElement->removeAttribute($attr->name);
            if ($n==='href' || $n==='xlink:href') {
                if (preg_match('/^\s*javascript:/i', $attr->value) || preg_match('/^\s*data\s*:\s*text\/html/i', $attr->value)) {
                    $attr->ownerElement->removeAttribute($attr->name);
                }
            }
        }
        $clean = $dom->saveXML();
    }
    libxml_clear_errors();
    libxml_use_internal_errors(false);
    if (function_exists('libxml_disable_entity_loader') && $prevDisable !== null) libxml_disable_entity_loader($prevDisable);

    $deny = [
        '/<script\b[^>]*>.*?<\/script>/is',
        '/on[a-z]+\s*=\s*"[^"]*"/i',
        "/on[a-z]+\s*=\s*'[^']*'/i",
        '/on[a-z]+\s*=\s*[^\s>]+/i',
        '/javascript\s*:/i',
        '/data\s*:\s*text\/html/i',
    ];
    $clean = preg_replace($deny, '', $clean);
    if (@file_put_contents($tmp, $clean) === false) $file['error']='SVGのサニタイズに失敗しました。';
    return $file;
});

// 5) サムネイル見た目調整
add_action('admin_head', function () {
    echo '<style>.attachment .thumbnail img[src$=".svg"],img[src$=".svg"].attachment-post-thumbnail,.media-icon img[src$=".svg"]{width:100%;height:auto;}</style>';
});

権限の増減(WP-CLI 例)

# 付与
wp cap add editor upload_svg
wp cap add author upload_svg

# 剥奪
wp cap remove author upload_svg

動作確認チェックリスト

  1. 許可ロールユーザーでログインして SVG をアップロードできるか
  2. 管理者以外・未許可ロールでは SVG が拒否されるか
  3. <script> を含む SVG をアップした際、保存前に除去されるか
  4. onload などのイベント属性が除去されるか
  5. href="javascript:..." などが削除されるか
  6. メディア一覧で SVG が崩れずにプレビュー表示されるか

よくあるつまずき

  • 「このファイルタイプはセキュリティ上の理由により許可されていません」
    → 対象ユーザーに upload_svg が付与されているか、MU プラグインが読み込まれているかを確認。
  • ほかの SVG プラグインと競合
    → いずれか一方に統一。MIME 許可やサニタイズ処理が二重になると判定がブレます。
  • テーマ版(functions.php)で反映が安定しない
    → MU プラグイン版へ移行を推奨。テーマ切替や子テーマ有無の影響を受けません。

まとめ

  • 誰が SVG をアップできるかcapability で管理し、最小権限を徹底する。
  • 偽装検知最低限のサニタイズでリスクを抑える。
  • 運用と体制に合わせて、MU プラグインで恒常的に適用。
  • さらなる堅牢化が必要なら 専用ライブラリの導入を検討。

関連記事

コメント

タイトルとURLをコピーしました