エスケープしていたのに、すり抜けられました。
2026年2月25日、Rapls AI Chatbot(無料版)の開発ブランチに、AI の返事を画面へ整形して表示する関数を1つ足しました。その翌日、自分でコードを読み返していて血の気が引きます。HTML はちゃんとエスケープしていたのに、無害化したはずの文字列が、画面に出す瞬間にすり抜けていました。
原因は、エスケープのあとに HTML 文字列を組み立てて、それを innerHTML に流し込んでいたことです。エスケープで作ったエンティティが、innerHTML のところでもう一度パースされて、無害化が解けていました。教訓は2段あります。LLM の出力は、利用者が入力欄に打つ文字列と同じ外部入力であること。そして、エスケープしただけでは足りず、安全なのは HTML 文字列を作らずに DOM を直接組むことだった、ということです。
幸い、これはリリース前の自分のレビューで掴まえました。問題のコードは開発ブランチに1日だけ存在したもので、WordPress.org に配布したどのタグにも入っていません。翌日に修正して、1.4.0 を出す前に閉じています。それでも自分の手で踏んだ穴なので、順に書き残します。以下、一緒にコードを追ってみてください。


上が信頼境界の図、下が今回の事故の流れです。利用者が打つ文字列も、AI が返す出力も、どちらも境界の外側にあります。AI の出力だけは「きれいなもの」だと感じてしまうのが、最初の油断でした。
検証環境:Rapls AI Chatbot(無料版)開発版 1.3.1 / WordPress 6.4 / PHP 8.2.29(Local by Flywheel)/ Chrome・Safari の双方で確認
混入 2026年2月25日 → 修正 2026年2月26日(配布タグには未到達、1.4.0 リリース前に修正)
エスケープしていたのに、すり抜けられました
まず、弱かった頃の関数を見てください。やっていること自体は、まっとうに見えます。最初に全部エスケープして、それから URL を自動リンクにして、改行を br に変える。順番も「先にエスケープ」で、正しそうです。
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 | formatBotMessage: function(text) { // 1. HTML エンティティをまず全部エスケープ(不正な HTML 混入を防ぐつもりだった) var safe = this.escapeHtml(text); // 2. URL を自動リンク化(http/https のみ、安全属性つき) safe = safe.replace( /https?:\/\/[^\s<>"')\]]+/g, function(url) { return '<a href="' + url + '" target="_blank" rel="noopener noreferrer">' + url + '</a>'; } ); // 3. 改行を <br> に safe = safe.replace(/\n/g, '<br>'); return safe; }, |
すり抜けの原因は、エスケープと自動リンクの組み合わせにありました。escapeHtml() は、ダブルクオートを " という文字参照に置き換えます。これでテキスト本文にクオートを書かれても安全に見えます。ところが、その直後の自動リンクの正規表現が [^\s<>"')\]]+ で URL を切り出している。ここで弾いているのはリテラルのダブルクオートだけで、エスケープ済みの " は弾けません。だから " が URL の一部として残り、文字列連結で href="…" の中へ入っていきます。
悪用する側は、ここに目をつけます。URL のクエリ部分に、エスケープ済みのダブルクオート相当の文字を仕込み、その後ろにイベントハンドラ属性に化ける並びを置く、という形です。具体的なペイロード文字列は、そのままコピーして試せる形にはしたくないので伏せますが、要は「自動リンクの正規表現を通り抜けるエスケープ済みクオート」と「属性に見える文字列」の組み合わせです。これが関数を抜けると、いったんは無害な文字列の見た目のまま、こういう構造になります。
| 1 | <a href="http://example.com/?z=(ここに、エスケープ済みクオート相当の文字と、属性に化ける文字列が入る/実際の中身は伏せます)" target="_blank" rel="noopener noreferrer">…</a> |
文字列として見ているうちは無害です。問題は、この文字列を画面に出す瞬間でした。呼び出し側は、組み立てた文字列を innerHTML に代入していました。
| 1 2 3 4 5 6 7 8 | if (role === 'bot') { var formatted = this.formatBotMessage(content); var textSpan = document.createElement('span'); textSpan.innerHTML = formatted; // ← ここが受け口 contentEl.appendChild(textSpan); } else { contentEl.textContent = content; // ユーザー発言は textContent のみ } |
innerHTML への代入は、HTML パーサをもう一度通します。すると、さきほど残っていた " が、今度は " に戻る。その瞬間、href の値が途中で閉じてしまい、本来データだったはずの後半部分が、独立したイベントハンドラ属性として DOM に並びました。上の2枚目の図が、その二度パースの流れです。エスケープした文字が innerHTML のところで復元され、無害化が解ける。Chrome でも Safari でも、同じように起きました。
ここで気づいたのは、「先にエスケープすれば安全」という思い込みが、そもそも間違いだった、ということです。穴を作っていたのは、特定の関数のバグというより、エスケープしてから文字列連結で HTML を組み立てて、それを innerHTML に流す、という処理の並びそのものでした。エスケープは一度きりの無害化のつもりでも、innerHTML がもう一度パースをかけるなら、無害化は途中で解けます。
余談ですが、私はこのとき初めて、自分が「エスケープは魔法の盾」だと無意識に思っていたことに気づきました。盾は、置く場所と回数を間違えると、ただの紙です。もうひとつ余談を続けると、このコードを書いた翌日に読み返す習慣がなければ、私はたぶん気づかないまま 1.4.0 を出していました。動いている画面はきれいでしたし、テストも通っていた。翌日の自分は他人だ、というのは本当だと思います。
そして、この危険な文字列がどこから来るかを考えると、話は AI に戻ります。モデルの出力は、利用者がプロンプトに書いた内容や、Web 検索で拾ってきた外部ページの内容を、形を変えて運んできます。利用者が「次の文字列をそのまま表示して」と頼めば、モデルは素直にそれを含む文章を返す。この危険な並びは、モデルの出力という入口から、いくらでも入ってきます。AI の出力を信用してそのまま描いた時点で、入口は開いていました。
直し方は、HTML 文字列を作るのをやめることでした
よくある選択は、出力した HTML を後段でフィルタにかける方式です。WordPress なら wp_kses() で許可リストのタグだけ残す、という手があります。でも、今回はそれを採りませんでした。もっと根本のところ、HTML 文字列を一切組み立てない、という方針に変えたのです。文字列を作らなければ innerHTML に流す必要もなく、二度目のパースも起きません。修正後の関数は、文字列ではなく DOM のかたまり(DocumentFragment)を組んで返します。
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 | formatBotMessage: function(text) { var fragment = document.createDocumentFragment(); var urlPattern = /https?:\/\/[^\s<>"')\]]+/g; var parts = text.split(urlPattern); var urls = text.match(urlPattern) || []; for (var i = 0; i < parts.length; i++) { var lines = parts[i].split('\n'); for (var j = 0; j < lines.length; j++) { if (j > 0) { fragment.appendChild(document.createElement('br')); } if (lines[j]) { fragment.appendChild(document.createTextNode(lines[j])); } } if (i < urls.length) { var a = document.createElement('a'); a.href = urls[i]; // ← プロパティ代入。HTML パースが走らない a.target = '_blank'; a.rel = 'noopener noreferrer'; a.textContent = urls[i]; // ← textContent。HTML として解釈されない fragment.appendChild(a); } } return fragment; }, |
効いているのは3か所です。テキストは createTextNode() で作るので、< や & を含んでいても HTML として解釈されず、そのまま文字として出ます。リンクの href は、文字列連結ではなくプロパティへの代入で入れます。プロパティ代入は HTML パーサを通らないので、属性を途中で閉じて別の属性を生やす、という芸当ができません。リンクのラベルも textContent です。安全属性の rel と target もプロパティで確定させているので、入力側からこの2つを上書きする経路がありません。呼び出し側も innerHTML を捨てて、組み上げた DOM を appendChild() で挿すだけに変えました。
| 1 2 3 4 5 6 7 8 | if (role === 'bot') { var formatted = this.formatBotMessage(content); var textSpan = document.createElement('span'); textSpan.appendChild(formatted); // ← DocumentFragment をそのまま注入 contentEl.appendChild(textSpan); } else { contentEl.textContent = content; } |

修正前は HTML 文字列を組み立てて innerHTML に流していた。修正後は文字列を作らず、createTextNode とプロパティ代入で DOM を直接組む。パーサを通らないので、二度目のパースが起きない。
その翌々日、2026年2月27日にはマークダウンの描画機能(見出し、リスト、テーブル、コードブロック、インラインの強調)を足しましたが、これも同じ思想で作りました。marked のような外部のマークダウンライブラリは使わず、自前で正規表現でトークンに分け、トークンごとに createElement と textContent で組み立てます。リンクのトークンも、URL を事前に https?: だけに制限してから、プロパティで href を入れています。
この作り方のいいところは、生成し得るタグの集合が、レンダラーの実装そのものに閉じ込められていることです。出てくるのは、段落、見出し、リスト、引用、コード、テーブル、強調、リンク、改行といった、自分が createElement で書いたタグだけ。それ以外は原理的に生成されません。許可リストを外に置いて事後にフィルタするのとは逆の発想で、生成側を閉じているので、wp_kses のような後段フィルタを噛ませる必要がない。下の図がその構造です。

なお PHP 側、つまり REST のレスポンスを組み立てるところでは、特別なサニタイズはしていません。LLM の応答は生のテキストのまま JSON で返して、表示の安全性はクライアント側の DOM 構築に一本化しています。安全を確かめる場所を1か所に集める、という判断です。
ひとつ補足しておきます。1文字ずつ描くストリーミング表示については、今回は何もしていません。理由は、そもそも実装していないからです。今の Web チャットは1回のレスポンスで全文を受け取って、一括で描画します。断片を届いたそばから innerHTML へ足していく経路が最初から存在しないので、そこに穴もありません。このあたりは、同じプラグインで会話の履歴が絡んだ別の事故もあって、AI 連携は仕様の理解と実機の挙動がずれやすいと何度も思い知らされています。その履歴まわりの話は WP AI Client へ移行したら、エラーも出さずに会話の履歴が消えた に書きました。プラグインの機能や設定そのものは、技術リファレンスにあります。
外部入力の入口が、また1つ増えた
振り返ると、今回いちばん効いたのは新しい言葉を覚えたことではありませんでした。AI を組み込むと、外部入力の入口が増える。その当たり前の事実に、自分の手で気づいたことのほうが大きかった。フォームからの入力を疑うのと同じ強さで、モデルの出力も疑う。出どころが外なら、見た目がどれだけ整っていても、描く前に一度せき止める。そして、せき止め方を間違えないこと。エスケープしたつもりでも、そのあと文字列を組んで innerHTML に流せば、無害化は解けます。だから、HTML 文字列を作らずに DOM を直接組む。
動いている画面を見ているだけでは、こういう穴は見えません。気になる入力を1つ試すまでは、何も起きていないように見えてしまう。リリース前のレビューでこれを掴まえられたのは、きれいに動いて見えるコードほど、誰の目で見ているかを疑ったからでした。
AI が生成したコードが、別の AI の出力を扱い、それをまた別のところへ渡していく。そういう連なりが当たり前になってきたとき、外部入力の境界をどこに引くかは、まだ自分の中でも固まっていません。ひとまず今日は、手元のコードを1か所だけ見てください。AI の出力から HTML 文字列を組み立てて、innerHTML に流している行はありませんか。あれば、そこが入口です。
関連記事
- WP AI Client へ移行したら、エラーも出さずに会話の履歴が消えた ── 同じプラグインで踏んだ、AI 連携の別の落とし穴。
- Brainfuck のベンチで AI が満点を取った、けれど「8 8」で正体が割れた話 ── AI の出力を鵜呑みにしない、という同じ姿勢の別の記録。
- WordPress で API キーを AES-256-GCM で保存する ── 同じプラグインの、鍵の置き場所についての実装記録。
エスケープしていたのに、すり抜けられました。
2026年2月25日、Rapls AI Chatbot(無料版)の開発ブランチに、AI の返事を画面へ整形して表示する関数を1つ足しました。その翌日、自分でコードを読み返していて血の気が引きます。HTML はちゃんとエスケープしていたのに、無害化したはずの文字列が、画面に出す瞬間にすり抜けていました。
原因は、エスケープのあとに HTML 文字列を組み立てて、それを innerHTML に流し込んでいたことです。エスケープで作ったエンティティが、innerHTML のところでもう一度パースされて、無害化が解けていました。教訓は2段あります。LLM の出力は、利用者が入力欄に打つ文字列と同じ外部入力であること。そして、エスケープしただけでは足りず、安全なのは HTML 文字列を作らずに DOM を直接組むことだった、ということです。
幸い、これはリリース前の自分のレビューで掴まえました。問題のコードは開発ブランチに1日だけ存在したもので、WordPress.org に配布したどのタグにも入っていません。翌日に修正して、1.4.0 を出す前に閉じています。それでも自分の手で踏んだ穴なので、順に書き残します。以下、一緒にコードを追ってみてください。


上が信頼境界の図、下が今回の事故の流れです。利用者が打つ文字列も、AI が返す出力も、どちらも境界の外側にあります。AI の出力だけは「きれいなもの」だと感じてしまうのが、最初の油断でした。
検証環境:Rapls AI Chatbot(無料版)開発版 1.3.1 / WordPress 6.4 / PHP 8.2.29(Local by Flywheel)/ Chrome・Safari の双方で確認
混入 2026年2月25日 → 修正 2026年2月26日(配布タグには未到達、1.4.0 リリース前に修正)
エスケープしていたのに、すり抜けられました
まず、弱かった頃の関数を見てください。やっていること自体は、まっとうに見えます。最初に全部エスケープして、それから URL を自動リンクにして、改行を br に変える。順番も「先にエスケープ」で、正しそうです。
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 | formatBotMessage: function(text) { // 1. HTML エンティティをまず全部エスケープ(不正な HTML 混入を防ぐつもりだった) var safe = this.escapeHtml(text); // 2. URL を自動リンク化(http/https のみ、安全属性つき) safe = safe.replace( /https?:\/\/[^\s<>"')\]]+/g, function(url) { return '<a href="' + url + '" target="_blank" rel="noopener noreferrer">' + url + '</a>'; } ); // 3. 改行を <br> に safe = safe.replace(/\n/g, '<br>'); return safe; }, |
すり抜けの原因は、エスケープと自動リンクの組み合わせにありました。escapeHtml() は、ダブルクオートを " という文字参照に置き換えます。これでテキスト本文にクオートを書かれても安全に見えます。ところが、その直後の自動リンクの正規表現が [^\s<>"')\]]+ で URL を切り出している。ここで弾いているのはリテラルのダブルクオートだけで、エスケープ済みの " は弾けません。だから " が URL の一部として残り、文字列連結で href="…" の中へ入っていきます。
悪用する側は、ここに目をつけます。URL のクエリ部分に、エスケープ済みのダブルクオート相当の文字を仕込み、その後ろにイベントハンドラ属性に化ける並びを置く、という形です。具体的なペイロード文字列は、そのままコピーして試せる形にはしたくないので伏せますが、要は「自動リンクの正規表現を通り抜けるエスケープ済みクオート」と「属性に見える文字列」の組み合わせです。これが関数を抜けると、いったんは無害な文字列の見た目のまま、こういう構造になります。
| 1 | <a href="http://example.com/?z=(ここに、エスケープ済みクオート相当の文字と、属性に化ける文字列が入る/実際の中身は伏せます)" target="_blank" rel="noopener noreferrer">…</a> |
文字列として見ているうちは無害です。問題は、この文字列を画面に出す瞬間でした。呼び出し側は、組み立てた文字列を innerHTML に代入していました。
| 1 2 3 4 5 6 7 8 | if (role === 'bot') { var formatted = this.formatBotMessage(content); var textSpan = document.createElement('span'); textSpan.innerHTML = formatted; // ← ここが受け口 contentEl.appendChild(textSpan); } else { contentEl.textContent = content; // ユーザー発言は textContent のみ } |
innerHTML への代入は、HTML パーサをもう一度通します。すると、さきほど残っていた " が、今度は " に戻る。その瞬間、href の値が途中で閉じてしまい、本来データだったはずの後半部分が、独立したイベントハンドラ属性として DOM に並びました。上の2枚目の図が、その二度パースの流れです。エスケープした文字が innerHTML のところで復元され、無害化が解ける。Chrome でも Safari でも、同じように起きました。
ここで気づいたのは、「先にエスケープすれば安全」という思い込みが、そもそも間違いだった、ということです。穴を作っていたのは、特定の関数のバグというより、エスケープしてから文字列連結で HTML を組み立てて、それを innerHTML に流す、という処理の並びそのものでした。エスケープは一度きりの無害化のつもりでも、innerHTML がもう一度パースをかけるなら、無害化は途中で解けます。
余談ですが、私はこのとき初めて、自分が「エスケープは魔法の盾」だと無意識に思っていたことに気づきました。盾は、置く場所と回数を間違えると、ただの紙です。もうひとつ余談を続けると、このコードを書いた翌日に読み返す習慣がなければ、私はたぶん気づかないまま 1.4.0 を出していました。動いている画面はきれいでしたし、テストも通っていた。翌日の自分は他人だ、というのは本当だと思います。
そして、この危険な文字列がどこから来るかを考えると、話は AI に戻ります。モデルの出力は、利用者がプロンプトに書いた内容や、Web 検索で拾ってきた外部ページの内容を、形を変えて運んできます。利用者が「次の文字列をそのまま表示して」と頼めば、モデルは素直にそれを含む文章を返す。この危険な並びは、モデルの出力という入口から、いくらでも入ってきます。AI の出力を信用してそのまま描いた時点で、入口は開いていました。
直し方は、HTML 文字列を作るのをやめることでした
よくある選択は、出力した HTML を後段でフィルタにかける方式です。WordPress なら wp_kses() で許可リストのタグだけ残す、という手があります。でも、今回はそれを採りませんでした。もっと根本のところ、HTML 文字列を一切組み立てない、という方針に変えたのです。文字列を作らなければ innerHTML に流す必要もなく、二度目のパースも起きません。修正後の関数は、文字列ではなく DOM のかたまり(DocumentFragment)を組んで返します。
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 | formatBotMessage: function(text) { var fragment = document.createDocumentFragment(); var urlPattern = /https?:\/\/[^\s<>"')\]]+/g; var parts = text.split(urlPattern); var urls = text.match(urlPattern) || []; for (var i = 0; i < parts.length; i++) { var lines = parts[i].split('\n'); for (var j = 0; j < lines.length; j++) { if (j > 0) { fragment.appendChild(document.createElement('br')); } if (lines[j]) { fragment.appendChild(document.createTextNode(lines[j])); } } if (i < urls.length) { var a = document.createElement('a'); a.href = urls[i]; // ← プロパティ代入。HTML パースが走らない a.target = '_blank'; a.rel = 'noopener noreferrer'; a.textContent = urls[i]; // ← textContent。HTML として解釈されない fragment.appendChild(a); } } return fragment; }, |
効いているのは3か所です。テキストは createTextNode() で作るので、< や & を含んでいても HTML として解釈されず、そのまま文字として出ます。リンクの href は、文字列連結ではなくプロパティへの代入で入れます。プロパティ代入は HTML パーサを通らないので、属性を途中で閉じて別の属性を生やす、という芸当ができません。リンクのラベルも textContent です。安全属性の rel と target もプロパティで確定させているので、入力側からこの2つを上書きする経路がありません。呼び出し側も innerHTML を捨てて、組み上げた DOM を appendChild() で挿すだけに変えました。
| 1 2 3 4 5 6 7 8 | if (role === 'bot') { var formatted = this.formatBotMessage(content); var textSpan = document.createElement('span'); textSpan.appendChild(formatted); // ← DocumentFragment をそのまま注入 contentEl.appendChild(textSpan); } else { contentEl.textContent = content; } |

修正前は HTML 文字列を組み立てて innerHTML に流していた。修正後は文字列を作らず、createTextNode とプロパティ代入で DOM を直接組む。パーサを通らないので、二度目のパースが起きない。
その翌々日、2026年2月27日にはマークダウンの描画機能(見出し、リスト、テーブル、コードブロック、インラインの強調)を足しましたが、これも同じ思想で作りました。marked のような外部のマークダウンライブラリは使わず、自前で正規表現でトークンに分け、トークンごとに createElement と textContent で組み立てます。リンクのトークンも、URL を事前に https?: だけに制限してから、プロパティで href を入れています。
この作り方のいいところは、生成し得るタグの集合が、レンダラーの実装そのものに閉じ込められていることです。出てくるのは、段落、見出し、リスト、引用、コード、テーブル、強調、リンク、改行といった、自分が createElement で書いたタグだけ。それ以外は原理的に生成されません。許可リストを外に置いて事後にフィルタするのとは逆の発想で、生成側を閉じているので、wp_kses のような後段フィルタを噛ませる必要がない。下の図がその構造です。

なお PHP 側、つまり REST のレスポンスを組み立てるところでは、特別なサニタイズはしていません。LLM の応答は生のテキストのまま JSON で返して、表示の安全性はクライアント側の DOM 構築に一本化しています。安全を確かめる場所を1か所に集める、という判断です。
ひとつ補足しておきます。1文字ずつ描くストリーミング表示については、今回は何もしていません。理由は、そもそも実装していないからです。今の Web チャットは1回のレスポンスで全文を受け取って、一括で描画します。断片を届いたそばから innerHTML へ足していく経路が最初から存在しないので、そこに穴もありません。このあたりは、同じプラグインで会話の履歴が絡んだ別の事故もあって、AI 連携は仕様の理解と実機の挙動がずれやすいと何度も思い知らされています。その履歴まわりの話は WP AI Client へ移行したら、エラーも出さずに会話の履歴が消えた に書きました。プラグインの機能や設定そのものは、技術リファレンスにあります。
外部入力の入口が、また1つ増えた
振り返ると、今回いちばん効いたのは新しい言葉を覚えたことではありませんでした。AI を組み込むと、外部入力の入口が増える。その当たり前の事実に、自分の手で気づいたことのほうが大きかった。フォームからの入力を疑うのと同じ強さで、モデルの出力も疑う。出どころが外なら、見た目がどれだけ整っていても、描く前に一度せき止める。そして、せき止め方を間違えないこと。エスケープしたつもりでも、そのあと文字列を組んで innerHTML に流せば、無害化は解けます。だから、HTML 文字列を作らずに DOM を直接組む。
動いている画面を見ているだけでは、こういう穴は見えません。気になる入力を1つ試すまでは、何も起きていないように見えてしまう。リリース前のレビューでこれを掴まえられたのは、きれいに動いて見えるコードほど、誰の目で見ているかを疑ったからでした。
AI が生成したコードが、別の AI の出力を扱い、それをまた別のところへ渡していく。そういう連なりが当たり前になってきたとき、外部入力の境界をどこに引くかは、まだ自分の中でも固まっていません。ひとまず今日は、手元のコードを1か所だけ見てください。AI の出力から HTML 文字列を組み立てて、innerHTML に流している行はありませんか。あれば、そこが入口です。
関連記事
- WP AI Client へ移行したら、エラーも出さずに会話の履歴が消えた ── 同じプラグインで踏んだ、AI 連携の別の落とし穴。
- Brainfuck のベンチで AI が満点を取った、けれど「8 8」で正体が割れた話 ── AI の出力を鵜呑みにしない、という同じ姿勢の別の記録。
- WordPress で API キーを AES-256-GCM で保存する ── 同じプラグインの、鍵の置き場所についての実装記録。


コメント