追記
この記事では、初回修正時に採用した
createTextNode()と<br>を組み合わせる方法を中心に記録しています。公開後、コメントで
innerTextを利用する方法をご提案いただきました。
実際に検証したところ、今回の用途ではHTMLとして解釈されない安全性と改行表示を維持したまま、より簡潔に実装できることを確認できたため、現在はinnerTextを利用しています。そのため、本文中の
createTextNode()や<br>によるDOM構築、DOM構造の検証結果は初回修正時点の記録です。
はじめに
個人開発中のFlaskアプリで、Codexを使ってセキュリティ面の見直しを進めています。
今回見つかったのは、AIが返した文章をJavaScriptのinnerHTMLで画面へ表示していた箇所です。
アプリとしては正常に動作していましたが、innerHTMLは文字列を単なるテキストではなくHTMLとして解釈します。
そのため、AI返答やユーザー入力を経由してHTML風の文字列が渡された場合、意図しない要素が生成される可能性があります。
初回修正ではこの箇所を、
-
innerHTMLを使わない - AI返答はTextノードとして扱う
- 改行だけ
<br>として生成する
という形に修正し、実際のGemini返答、DOM構造、HTML風文字列、JavaScriptエラーまで確認しました。
その後、記事公開後のコメントをきっかけにinnerTextを利用する方法も検証し、現在はより簡潔な実装へリファクタリングしています。
対象アプリ
対象は、ベーカリー店舗向けに開発している売上管理・分析アプリです。
主な構成は以下です。
- 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要素にはなりません。
なお、この方法は初回修正時に採用した実装です。
記事公開後の検証を経て、現在はinnerTextを利用する形へリファクタリングしています。
初回修正では改行だけを<br>として生成
AI返答は複数行になるため、改行表示は残したい機能です。
初回修正では文字列を\nで分割し、行と行の間だけ、
document.createElement('br')
で<br>を生成しました。
つまり当時のDOM構造は、
Textノード
BR
Textノード
BR
Textノード
のようになります。
AI返答そのものはすべてTextノードです。
HTMLとして生成するのは改行用の<br>だけにしました。
現在はinnerTextとCSSのwhite-space: pre-lineを組み合わせることで、同じ目的をより簡潔な処理で実現しています。
ローディング・エラー表示もtextContentへ変更
固定文についてもinnerHTMLを使う必要がなかったため、textContentへ変更しました。
例えば、
aiText.textContent = '🌀 AIアシスタントが詳しい改善案を考えています...';
のような形です。
固定メッセージでも、HTMLを使う理由がなければtextContentを使う方が意図が明確になります。
Jinja側のsafeも削除
初期表示はJinjaの自動エスケープを有効にした状態へ戻しました。
{{ ai_advice }}
改行についてはCSS側で、
white-space: pre-line;
を使用しました。
これにより、Jinja側でHTMLを生成せずに改行を維持できます。
このCSSは、その後innerTextへリファクタリングした現在の実装でも利用しています。
修正後に不要な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要素として解釈されず、単なる文字列として扱われていました。
この検証結果は、createTextNode()と<br>を利用していた初回修正時点の記録です。
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だけでは気付けない可能性があります。
この課題をきっかけに、以降は実際に見つかった不具合や脆弱性を回帰テストとして残す方向で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しています。
その後、記事公開後のコメントをきっかけにinnerTextを利用する方法を検証し、現在の実装へリファクタリングしています。
今回学んだこと
1. 「AIが危険な文字列を返さないだろう」で設計しない
AIが通常は文章を返すとしても、それを前提に安全性を決めるべきではありません。
入力元がAIであっても、ユーザーであっても、外部APIであっても、
外から来た文字列は文字列として扱う
ことが重要だと感じました。
2. HTMLが不要ならinnerHTMLを使わない
今回必要だったのは文章と改行だけでした。
そのため、
innerHTML
を使う必要はありませんでした。
初回修正ではTextノードと<br>を使用しました。
さらに記事公開後の検証では、今回の用途ならinnerTextとwhite-space: pre-lineでも目的を達成できることを確認しました。
大切なのは特定の方法にこだわることではなく、
HTMLとして解釈させる必要がない場所では、そもそもinnerHTMLを使わない
という設計だと感じました。
3. safeは便利だが、自動エスケープを無効化する
Jinjaのsafeは便利ですが、
「なぜsafeが必要なのか」
を説明できない場所では、使わない方が安全です。
今回の初期表示では、CSSのwhite-space: pre-lineで目的を達成できました。
4. 修正後はDOMまで確認すると分かりやすい
画面を見て正常に表示されているだけでは、
「内部でHTML要素が生成されていないか」
までは分かりません。
初回修正では、
Textノード
BR
Textノード
というDOM構造を直接確認したことで、HTML風文字列が要素として生成されていないことを具体的に確認できました。
その後、実装方法自体はinnerTextへ変更しましたが、
見た目だけではなく、内部でどのように扱われているかまで確認する
という考え方は今後も続けたいと思います。
5. セキュリティ対策は「同じミスをしない」ではなく「同じ状態を作らない」
今回の修正で特に感じたのは、この考え方です。
「危険なHTMLを入力しないようにする」
ではなく、
危険なHTML風文字列が入ってきても、HTMLとして実行されない状態にする
ことが重要です。
これは普段の仕事で行っている安全管理にも似ていると感じました。
事故やヒヤリハットが発生したとき、
「次から気をつける」
だけでは、同じ条件がそろえば再発する可能性があります。
それよりも、
二度と同じ危険な状況・状態にならない仕組みを作る
ことが再発防止になります。
システム開発でも同じ考え方が使えると感じました。
今後の改善
この記事執筆時点では、AI返答表示におけるXSSリスクを修正し、ブラウザ・DOM・pytestで動作確認を行いました。
ただし、当時のpytestはまだ検証範囲が狭く、今回のような問題を自動的に検出できる状態にはなっていませんでした。
そこで次の改善として、これまで実際に見つかった不具合や脆弱性を、
「二度と同じ状況にならないための再発防止テスト」
としてpytestへ追加していくことにしました。
単に「同じミスをしないよう気をつける」のではなく、
同じ危険な状態になった時点でテストが失敗する仕組み
にしていきたいと考えています。
次回は、pytestを単なる動作確認ではなく、
過去の不具合・ヒヤリハットを記録する「事故防止台帳」として強化する過程
をまとめる予定です。
おわりに
今回の問題は、アプリが動かなくなるような分かりやすいエラーではありませんでした。
正常に動いているように見えていても、内部にはセキュリティ上の弱点が残っている場合があります。
個人開発では、機能を作ることに意識が向きやすいですが、
- 小さな不具合
- 小さな設計上の弱点
- 小さなヒヤリハット
を一つずつ見つけて修正し、その経験をテストへ残していくことが、システムを強くしていくのだと思います。
また、今回の記事公開後には、コメントでinnerTextを利用する方法をご提案いただきました。
提案をそのまま採用するのではなく、実際に置き換えたうえで、
- HTML風文字列がHTMLとして解釈されないこと
- 改行表示が維持できること
- 既存の表示を壊さないこと
- テストへの影響がないこと
を確認しました。
その結果、今回の用途では安全性と改行表示を維持しながら実装を簡潔にできることを確認できたため、現在はinnerTextを利用する形へリファクタリングしています。
そのため、本文で紹介したcreateTextNode()と<br>による実装は、初回修正時点の記録となっています。
今回の経験から、
AIからの提案でも、人からの提案でも、そのまま採用するのではなく、自分の環境で検証してから判断する
ことの大切さも改めて感じました。
今後もCodexをコード生成だけに使うのではなく、
レビュー → 修正 → 検証 → 再発防止
まで一緒に進める形で活用していきます。
