先に、いちばん大事な一行を書きます。LLMの返答は、ユーザーが打ち込んだ入力と同じくらい、信用できません。なのに多くの実装は、LLMの出力を「自前で作った安全なデータ」のように扱って、そのまま画面に流し込みます。ここが、出力インジェクションの入口です。
自分は、AIチャットボットのプラグインを作っていて、リリース後に35個のバグを踏みました。いちばん怖かったのは、入力側ではなく、出力側でした。この記事は、そのとき塞いだ観点を、実装チェックリストの形で残します。
そもそも、何が危ないのか
普通、開発者はユーザー入力を疑います。エスケープする、サニタイズする。でも、LLMの返答に対しては、その警戒がすっぽり抜けがちです。
理由はたぶん、LLMの出力が「自分のシステムの内側から来たもの」に見えるからです。実際には違います。LLMは、ユーザーのプロンプトや、外部から取り込んだ文書を材料に、文章を生成する。つまりLLMの返答は、外部の入力が形を変えて戻ってきたものです。信頼できる出どころ、ではありません。
自分はこれを「二重trust問題」と呼んでいます。ユーザー入力は疑うのに、それをLLMに通した"だけ"の出力を、なぜか信じてしまう。プロンプトインジェクションで仕込まれた指示が、返答の中に <script> として紛れていても、そのまま出力すれば動きます。
チェックリスト
上から順に、実装で確認する観点です。
1. LLMの返答を innerHTML に入れない
いちばんの定番です。返答をそのまま innerHTML に流すと、返答に含まれるタグが実行されます。
// ❌ NG:返答をそのままHTMLとして挿入
chatBox.innerHTML = response.text;
// ✅ OK:テキストとして入れる(タグは文字列のまま表示される)
chatBox.textContent = response.text;
マークダウンを表示したいなら、innerHTML 直挿しではなく、サニタイズを通したうえでHTMLにします。
// ✅ Markdownを出すなら、サニタイザを挟む(例:DOMPurify)
chatBox.innerHTML = DOMPurify.sanitize(marked.parse(response.text));
2. サーバー側でも、出力をエスケープする
フロントだけの対策は、片手落ちです。返答をPHP側でHTMLに埋め込む箇所や、REST経由で返す箇所でも、エスケープします。
// ❌ NG:返答をそのままHTMLに埋める
echo '<div class="msg">' . $llm_reply . '</div>';
// ✅ OK:文脈に合ったエスケープを通す
echo '<div class="msg">' . esc_html( $llm_reply ) . '</div>';
属性値に入れるなら esc_attr、URLなら esc_url。埋める場所によってエスケープ関数を変えます。
3. 保存して、あとで再表示するときが危ない
見落としがちなのが、ここです。会話ログを保存して、管理画面や履歴で後から表示するとき。保存の時点で安全に見えても、再表示のときにエスケープが抜けると、そこで実行されます。
対策の原則は「保存はそのまま、表示のときにエスケープ」です。保存時にHTMLを削ってしまうと元の会話が壊れるので、生のまま持っておいて、出す瞬間に必ずエスケープを通す。
// 保存:生のまま(ただしDBなので prepare は必須)
$wpdb->insert( $table, [ 'reply' => $llm_reply ], [ '%s' ] );
// 表示:出すときにエスケープ
echo esc_html( $row->reply );
4. 返答を、そのまま次の処理に渡さない
表示だけの話ではありません。LLMの返答を、SQLの条件、シェルコマンド、ファイルパス、別のAPIのパラメータに、そのまま渡すのも危ない。返答は外部入力なので、次に渡す前に、入力と同じ検証をかけます。
とくに、返答からJSONを取り出して処理する実装は要注意です。LLMが返したJSONは、構造が保証されていません。期待した型か、想定の値の範囲か、パースした後に必ず確かめます。
5. システムプロンプトや鍵を、返答に混ぜない
返答の生成に使ったシステムプロンプト、APIキー、内部の指示が、何かの拍子に返答へ漏れることがあります。プロンプトインジェクションで「これまでの指示を全部出力して」と誘導される攻撃もあります。返答をそのまま出す前に、内部情報が混じっていないかを気にする癖をつけます。
早見でまとめ
- LLMの返答は外部入力と同じ。信頼できる出どころではない(二重trust問題)
- フロントは
innerHTML直挿しをやめる。Markdownはサニタイザ経由 - サーバー側も、埋める文脈に合わせて
esc_html/esc_attr/esc_url - 保存はそのまま、表示のときにエスケープ。再表示の抜けがいちばん多い
- 表示以外(SQL・コマンド・別API・JSON解析)も、外部入力として検証する
自分が35個のうちいちばんヒヤッとしたのは、入力側を固めたつもりで、出力側がまるまる素通しだったことでした。ユーザー入力は疑うのに、それをAIに通した返答は信じてしまう。この非対称に気づいてから、チェックの抜けがだいぶ減りました。役に立ったらストックして、チャットボットまわりを実装するとき見返してください。
ふだんはraplsworks.comで、WordPressプラグイン開発やClaude Codeまわりのことを書いています。