はじめに
個人開発中のFlaskアプリで、Codexを使ってセキュリティ面の見直しを進めています。
今回見つかったのは、AIが返した文章をJavaScriptのinnerHTMLで画面へ表示していた箇所です。
アプリとしては正常に動作していましたが、innerHTMLは文字列を単なるテキストではなくHTMLとして解釈します。
そのため、AI返答やユーザー入力を経由してHTML風の文字列が渡された場合、意図しない要素が生成される可能性があります。
今回はこの箇所を、
-
innerHTMLを使わない - AI返答はTextノードとして扱う
- 改行だけ
<br>として生成する
という形に修正し、実際のGemini返答、DOM構造、HTML風文字列、JavaScriptエラーまで確認しました。
対象アプリ
対象は、ベーカリー店舗向けに開発している売上管理・分析アプリです。
主な構成は以下です。
- Python
- Flask
- PostgreSQL
- SQLAlchemy
- Alembic
- JavaScript
- Gemini API
- Docker
- pytest
- GitHub Actions
売上データをもとに、Gemini APIから経営アドバイスを取得して画面へ表示する機能があります。
Codexのレビューで見つかった問題
Codexにセキュリティ面を中心とした確認を依頼したところ、AI返答の表示処理にinnerHTMLが使われていることが分かりました。
例えば、ダッシュボード側では次のような処理を行っていました。
aiText.innerHTML = data.ai_advice.replace(/\n/g, '<br>');
入力画面のAIあいさつでも同様です。
greetingText.innerHTML = data.message.replace(/\n/g, '<br>');
改行を<br>へ変換するために使っていた処理ですが、問題はinnerHTMLです。
例えば次の文字列が渡された場合、
<b>AIテスト</b>
innerHTMLでは<b>が文字として表示されるのではなく、HTML要素として解釈されます。
今回の機能ではAIから返ってくる文章を表示しているため、
「AIが普通の文章しか返さないだろう」
と考えてしまえば、そのままでも動いてしまいます。
しかし、セキュリティ対策として重要なのは、
危険な文字列が来ないことを期待するのではなく、危険な文字列が来ても危険な状態にならないようにすること
だと考えました。
初期表示にもsafeが使われていた
ダッシュボードの初期表示では、Jinja側でも次のような処理がありました。
{{ ai_advice | replace('\n', '<br>') | safe }}
safeを指定すると、Jinjaの自動エスケープを無効化します。
現時点では初期表示に固定文を使っていたため、すぐに問題になる状態ではありませんでした。
ただし、将来この値が動的な内容へ変更された場合、同じような危険が発生する可能性があります。
そのため今回、こちらも合わせて見直しました。
修正方針
今回はHTMLを許可する必要がありませんでした。
必要なのは、
- 日本語の文章
- 複数行表示
- 箇条書き記号
- 改行
だけです。
そのため、DOMPurifyなどのサニタイズライブラリを追加するのではなく、
HTMLとして解釈させない
という方針を採用しました。
追加した処理は次のようなものです。
function setTextWithLineBreaks(element, value) {
const fragment = document.createDocumentFragment();
const lines = String(value ?? '').split('\n');
lines.forEach((line, index) => {
if (index > 0) {
fragment.append(document.createElement('br'));
}
fragment.append(document.createTextNode(line));
});
element.replaceChildren(fragment);
}
ポイントは、
document.createTextNode(line)
です。
これにより、
<b>AIテスト</b>
のような文字列が渡されても、HTML要素にはなりません。
改行だけは<br>として生成
AI返答は複数行になるため、改行表示は残したい機能です。
そこで文字列を\nで分割し、行と行の間だけ、
document.createElement('br')
で<br>を生成しました。
つまりDOM構造は、
Textノード
BR
Textノード
BR
Textノード
のようになります。
AI返答そのものはすべてTextノードです。
HTMLとして生成するのは改行用の<br>だけにしました。
ローディング・エラー表示もtextContentへ変更
固定文についてもinnerHTMLを使う必要がなかったため、textContentへ変更しました。
例えば、
aiText.textContent = '🌀 AIアシスタントが詳しい改善案を考えています...';
のような形です。
固定メッセージでも、HTMLを使う理由がなければtextContentを使う方が意図が明確になります。
Jinja側のsafeも削除
初期表示はJinjaの自動エスケープを有効にした状態へ戻しました。
{{ ai_advice }}
改行についてはCSS側で、
white-space: pre-line;
を使用しました。
これにより、Jinja側でHTMLを生成せずに改行を維持できます。
修正後にinnerHTMLが残っていないことを確認
Codexで対象テンプレートを検索し、
templates/dashboard.htmltemplates/input.html
の両方から不要なinnerHTMLがなくなっていることを確認しました。
変更対象はこの2ファイルだけです。
templates/dashboard.html
templates/input.html
実ブラウザで検証
コードを修正しただけでは終わらせず、今回はヘッドレスChromeを使用してブラウザ上でも確認しました。
1. dashboard初期表示
ダッシュボードの初期表示を確認しました。
- AI経営アドバイス欄:正常
- ボタン:正常
- グラフ:正常
- レイアウト崩れ:なし
- 水平方向の超過:なし
初期メッセージには実際の改行がなかったため、ブラウザDOM上だけで次の文字列を設定して確認しました。
初期表示1行目
初期表示2行目
結果、
lineBreakPreserved=true
となり、改行が維持されました。
2. 実際のGemini返答を確認
次に、実際にGemini APIから経営アドバイスを1回取得しました。
HTTP 200
Gemini AI advice generated successfully
AI返答は日本語で正常に表示され、複数行も維持されました。
Markdown風の、
*
**
などもHTMLには変換されず、文字として表示されました。
DOMを確認すると、
#text
BR
#text
BR
#text
BR
#text
BR
#text
となっていました。
確認値は次の通りです。
Textノード: 5個
BR要素: 4個
BR以外の子要素: 0個
AI返答からHTML要素は生成されていません。
3. input画面のAIあいさつ
入力画面のAIあいさつも確認しました。
HTTP 200
AI daily greeting generated successfully
返答は日本語2行で正常表示されました。
DOM構造は、
#text
BR
#text
確認値は、
Textノード: 2個
BR要素: 1個
BR以外の子要素: 0個
でした。
フォームやボタンのレイアウト崩れもありませんでした。
HTML風文字列を使った安全性確認
次に、DBやGemini APIへ送信せず、ブラウザ上だけで次の文字列を表示させました。
<b>AIテスト</b>
<div>テスト</div>
<em>安全確認</em>
dashboardとinputの両方で結果は同じでした。
b/div/em要素: 0個
BR要素: 2個
DOMは、
#text
BR
#text
BR
#text
です。
Textノードにはそのまま、
<b>AIテスト</b>
<div>テスト</div>
<em>安全確認</em>
が入っていました。
HTML表現として確認すると、
<b>AIテスト</b><br>
<div>テスト</div><br>
<em>安全確認</em>
となりました。
つまり、
<b>
<div>
<em>
はHTML要素として解釈されず、単なる文字列として扱われています。
JavaScriptエラーも確認
Chrome DevTools Protocolで、
Runtime.exceptionThrown
console.error
を監視しました。
結果は、
JavaScriptエラー: 0件
でした。
Chrome自体のGPUやinotifyに関する警告はありましたが、アプリ側のJavaScriptエラーではありませんでした。
pytest
既存のpytestも実行しました。
PYTHONDONTWRITEBYTECODE=1 pytest -p no:cacheprovider
結果、
collected 3 items
test_prompts.py ... [100%]
3 passed in 0.40s
すべて成功しました。
ただし、ここで一つ課題も見つかりました。
現在のpytestはまだ3件しかなく、今回のXSS問題そのものを自動検出できる状態ではありません。
つまり、
今回の修正が将来壊れても、現在のpytestだけでは気付けない可能性があります。
git diffも確認
コミット前に、
git diff --check
を実行し、空白エラーがないことを確認しました。
変更対象も、
M templates/dashboard.html
M templates/input.html
の2ファイルだけでした。
commit・push
検証完了後、次のコミットを作成しました。
f354d5d fix: sanitize AI response rendering
origin/mainへpushしています。
今回学んだこと
1. 「AIが危険な文字列を返さないだろう」で設計しない
AIが通常は文章を返すとしても、それを前提に安全性を決めるべきではありません。
入力元がAIであっても、ユーザーであっても、外部APIであっても、
外から来た文字列は文字列として扱う
ことが重要だと感じました。
2. HTMLが不要ならinnerHTMLを使わない
今回必要だったのは文章と改行だけでした。
そのため、
innerHTML
を使う必要はありませんでした。
Textノードと<br>だけで十分です。
3. safeは便利だが、自動エスケープを無効化する
Jinjaのsafeは便利ですが、
「なぜsafeが必要なのか」
を説明できない場所では、使わない方が安全です。
今回の初期表示では、CSSのwhite-space: pre-lineで目的を達成できました。
4. 修正後はDOMまで確認すると分かりやすい
画面を見て正常に表示されているだけでは、
「内部でHTML要素が生成されていないか」
までは分かりません。
今回、
Textノード
BR
Textノード
というDOM構造を直接確認したことで、安全化できていることを具体的に確認できました。
5. セキュリティ対策は「同じミスをしない」ではなく「同じ状態を作らない」
今回の修正で特に感じたのは、この考え方です。
「危険なHTMLを入力しないようにする」
ではなく、
危険なHTML風文字列が入ってきても、HTMLとして実行されない状態にする
ことが重要です。
これは普段の仕事で行っている安全管理にも似ていると感じました。
事故やヒヤリハットが発生したとき、
「次から気をつける」
だけでは、同じ条件がそろえば再発する可能性があります。
それよりも、
二度と同じ危険な状況・状態にならない仕組みを作る
ことが再発防止になります。
システム開発でも同じ考え方が使えると感じました。
今後の改善
今回、AI返答表示におけるXSSリスクを修正し、ブラウザ・DOM・pytestで動作確認を行いました。
ただし、現在のpytestはまだ検証範囲が狭く、今回のような問題を自動的に検出できる状態にはなっていません。
そこで次の改善として、これまで実際に見つかった不具合や脆弱性を、
「二度と同じ状況にならないための再発防止テスト」
としてpytestへ追加していく予定です。
単に「同じミスをしないよう気をつける」のではなく、
同じ危険な状態になった時点でテストが失敗する仕組み
にしていきたいと考えています。
次回は、pytestを単なる動作確認ではなく、
過去の不具合・ヒヤリハットを記録する「事故防止台帳」として強化する過程
をまとめる予定です。
おわりに
今回の問題は、アプリが動かなくなるような分かりやすいエラーではありませんでした。
正常に動いているように見えていても、内部にはセキュリティ上の弱点が残っている場合があります。
個人開発では、機能を作ることに意識が向きやすいですが、
- 小さな不具合
- 小さな設計上の弱点
- 小さなヒヤリハット
を一つずつ見つけて修正し、その経験をテストへ残していくことが、システムを強くしていくのだと思います。
今後もCodexをコード生成だけに使うのではなく、
レビュー → 修正 → 検証 → 再発防止
まで一緒に進める形で活用していきます。
追記:コメントでinnerTextの利用についてご指摘いただき、実際に検証したところ、今回の用途では同等の安全性と改行表示を維持しつつ、より簡潔に実装できることを確認しました。現在はinnerTextを利用する形へリファクタリングしました。
