WordPressに
保存したSVGの一部の属性が消えていた問題
(svg-eyecatch-icon
プラグイン、サニタイズ)

以前、WordPressで投稿のタイトル前にSVGアイコンを表示するプラグインを開発しました。
ところが、透明感のある要素がうまく表示されない問題に気づきました。

プレビュー画面では半透明処理がきれいに表示されるのに、実際のページでは透明度が反映されていないのです。

関連記事

1. 問題の発見

プラグインの動作確認をしていると、不思議な現象に気づきました。
管理画面のプレビュータブでは、SVGアイコンのopacity属性が正しく反映されて半透明になっています。
しかし、実際の投稿ページを見ると、不透明なままでした。

最初は「表示の仕組みが違うのかな」と思いましたが、よく考えると同じデータベースから取得したSVGコードのはずです。
なぜプレビューとページで違う結果になったのでしょうか。

2. 原因の調査

コードを読み進めると、SVGの処理が3つの場所で行われていることがわかりました。

  • 1つ目は、管理画面のプレビュー表示です。
    ここでは投稿者が入力したSVGコードをesc_textarea()でエスケープして表示していました。
    つまり、入力したままの状態です。
  • 2つ目は、データベースへの保存処理です。
    ここに重要な処理がありました。
    wp_kses()という関数を使って、SVGコードをサニタイズしていたのです。
  • 3つ目は、実際のページでの表示です。
    データベースから取得したSVGコードをそのまま出力していました。

この流れを整理すると、問題の本質が見えてきます。

3. wp_ksesのサニタイズの仕組み

wp_kses()は、WordPressのセキュリティ機能の1つです。
許可されたHTMLタグと属性だけを残し、それ以外を削除します。
これをホワイトリスト方式と呼びます。

プラグインではget_allowed_svg_tags()というメソッドで、使用を許可するSVGの要素と属性を定義していました。
例えばpath要素には、こんな属性を許可していました。

'path' => array(
    'd' => array(),
    'fill' => array(),
    'stroke' => array(),
    'stroke-width' => array(),
    // ...
),Code language: PHP (php)

しかし、この一覧にopacityが含まれていませんでした。
つまり、保存時にopacity属性が削除されていたのです。

プレビューは入力値をそのまま表示するためopacityが見えます。
でもデータベースに保存された時点でopacityは消えています。
だからページでは反映されなかったわけです。

4. 最初の解決案とその問題点

「それならopacityを許可リストに追加すればいい」と考えました。
実際、各SVG要素の定義に'opacity' => array(),を追加していきました。

'path' => array(
    'd' => array(),
    'fill' => array(),
    'stroke' => array(),
    'opacity' => array(),  // 追加
    // ...
),Code language: PHP (php)

しかし、すぐに別の疑問が浮かびます。
opacity以外にも、fill-opacitystroke-opacityfill-ruleなど、SVGには多くの属性があります。
さらにclipPathmaskpatternといった要素も抜けていました。

全部を手動で追加していくのは大変です。
しかも、新しいSVG仕様が出たら、また追加作業が必要になります。

5. 発想の転換

ここで考え方を変えてみました。

本当に必要なのは「使えるものを列挙すること」でしょうか。
むしろ「危険なものを除外すること」が本質ではないでしょうか。

セキュリティの観点で危険なのは、主にこれらです。

  • <script>タグ(JavaScriptの実行)
  • onclickなどのイベントハンドラ(JavaScriptの実行)
  • javascript:プロトコル(JavaScriptの実行)
  • <iframe><object>(外部コンテンツの埋め込み)

逆に言えば、これらさえブロックできれば、他のSVG要素や属性は自由に使えるはずです。
これをブラックリスト方式と呼びます。

6. ブラックリスト方式への変更

validate_svg()メソッドを強化して、危険な要素だけをチェックするようにしました。

// 危険なタグをチェック
$dangerous_tags = array('script', 'iframe', 'object', 'embed', 'link', 'meta', 'base');
foreach ($dangerous_tags as $tag) {
    if (preg_match('/<' . $tag . '[^>]*>/i', $svg_code)) {
        return array('valid' => false, 'message' => $tag . 'タグは許可されていません。');
    }
}

// 危険な属性をチェック
$dangerous_attrs = array('onload', 'onerror', 'onclick', 'onmouseover', /* ... */);
foreach ($dangerous_attrs as $attr) {
    if (preg_match('/' . $attr . '\s*=/i', $svg_code)) {
        return array('valid' => false, 'message' => $attr . '属性は許可されていません。');
    }
}

// 危険なプロトコルをチェック
if (preg_match('/javascript:/i', $svg_code)) {
    return array('valid' => false, 'message' => 'javascript:プロトコルは許可されていません。');
}Code language: PHP (php)

この検証を通過したSVGコードは、そのままデータベースに保存します。
wp_kses()は使いません。

// 検証を通過したSVGをそのまま保存
update_post_meta($post_id, '_svg_eyecatch_code', $svg_code);Code language: PHP (php)

プレビュー表示も同様に変更しました。
検証済みのSVGコードはそのまま表示します。

7. 不要なコードの削除

ホワイトリスト方式を廃止したことで、get_allowed_svg_tags()メソッドは不要になりました。
200行以上あった要素と属性の定義を、まるごと削除できました。

コードがシンプルになり、メンテナンスも楽になります。

8. 結果の確認

修正後、改めて動作を確認しました。

管理画面でopacity="0.5"を含むSVGコードを入力すると、プレビューで半透明に表示されます。
投稿を保存してページを表示すると、こちらも同じように半透明になりました。

データベースに保存されたSVGコードを確認すると、opacity属性がそのまま残っています。

他にもfill-opacitystroke-opacityclipPathなどを試しましたが、すべて正しく動作しました。
新しいSVG属性を使っても、追加の設定は不要です。

9. セキュリティの検証

安全性も確認しておきます。

<script>タグを含むSVGコードを入力すると、保存時にエラーメッセージが表示されました。
「scriptタグは許可されていません。」というメッセージです。

onclick属性やjavascript:プロトコルも、同様にブロックされました。

必要な安全性は保ちつつ、SVGの柔軟性も確保できています。

10. 学んだこと

今回の開発を通じて、セキュリティ対策における2つのアプローチを体験しました。

  • ホワイトリスト方式は、安全だとわかっているものだけを許可します。確実ですが、すべてを列挙する必要があります。
  • ブラックリスト方式は、危険だとわかっているものだけを禁止します。柔軟ですが、見落としがあると危険です。

どちらが優れているということではありません。状況に応じて使い分けることが大切です。

SVGのような標準化された技術では、危険な要素が限定的です。そのため、ブラックリスト方式が適していました。

一方、ユーザー入力を完全に自由にする場合は、ホワイトリスト方式の方が安全でしょう。

11. おわりに

属性が消える原因は、ホワイトトリスト方式のサニタイズにありました。ブラックリスト方式に切り替えることで、すべてのSVG機能が使えるようになりました。

この変更により、プラグインは以下の点で改善されました。

  • すべてのSVG属性と要素に対応
  • 新しいSVG仕様にも自動対応
  • コードが200行以上削減されメンテナンスが容易に

同じような問題に直面している方の参考になれば幸いです。