コードレビューで見つかった問題を、勉強のためにまとめたものです(修正済み)。
この記事でわかること
- 自分のコードに潜んでいた XSS というクリティカルな問題
- 危険なのは
<script>だけじゃない、という属性レベル・スキームレベルの視点 - 「保存する入口」で無害化しておくのがなぜ正しいか(出口=表示する場所は後から増える)
- 正しい直し方と、今後同じミスをしないための教訓
修正前のコード
ユーザーが自由に HTML を入力できるフィールドがあり、その内容を サニタイズせずそのまま保存・返却 していた。
# モデル(修正前のイメージ。実体は AlbumCustomPage の html_content)
class UserHtmlContent(models.Model):
html_content = models.TextField() # サニタイズなしで保存
...
# シリアライザ(修正前)
class UserHtmlContentSerializer(serializers.ModelSerializer):
class Meta:
model = UserHtmlContent
fields = ['html_content', ...] # 入力をそのまま保存していたなぜ危険か
ここで正直に補足しておく。このフィールドの内容を今表示しているのは、保存した本人だけ(取得経路が owner 限定)。なので「悪意ある第三者のスクリプトを、何も知らない他人のブラウザで実行させる」という典型的な XSS が、今この瞬間に成立しているわけではない。今のリスクは「自分が貼った(あるいは何かの拍子に紛れ込んだ)スクリプトが、自分のセッションで動く」という self-XSS 寄りのもの。
ただ、ここがポイントだ。このアプリは「ページを共有する」方向の機能を持っています。いつかこの HTML を他人に見せる経路を足した瞬間、保存済みの無害化していないスクリプトは本物の第三者 XSS に変わります。XSS の怖さは「保存したものが、後からどこに表示されるか分からない」ところにあります。
対策
大前提: クライアント側だけのサニタイズはダメ。攻撃者は API を直接叩けばフロントの JS を完全にバイパスできるから。
サーバーサイド: nh3 で保存前に無害化する
DRF の validate_<フィールド名> メソッドにサニタイズを通します。validate_html_content を書いておくと、is_valid() のタイミングで自動的に呼ばれ、戻り値が保存値に差し替わります。
import nh3
# 残してよいタグのホワイトリスト(これ以外のタグは中身だけ残して除去)
_ALLOWED_TAGS = {"p", "br", "strong", "em", "ul", "ol", "li", "h2", "h3"}
class AlbumCustomPageSerializer(serializers.ModelSerializer):
def validate_html_content(self, value: str) -> str:
# tags = 許可タグだけ残す / attributes={} = 属性は一切残さない
return nh3.clean(value, tags=_ALLOWED_TAGS, attributes={})
class Meta:
model = AlbumCustomPage
fields = ["id", "html_content", "created_at"]ポイントは 属性を全部落としている(attributes={}) こと。onclick や onerror のようなイベント属性、href="javascript:..." のような危険なリンクは、属性ごと許可しなければそもそも残りません。リンク(<a>)も今回は許可タグに入れていないので、href のスキームを個別に弾く設定すら要らない。「許可するものを最小に絞る」 のが一番事故りにくいです。
クライアントサイド: 今は生 HTML を描画していない
多層防御の考え方として「表示する直前にもサニタイズする」(JS の DOMPurify など)というのがあります。ただ綴Photo の現状では、フロントはこの html_content を生 HTML として描画していない ので、クライアント側のサニタイズは入れていません。
もし将来フロントで生 HTML を dangerouslySetInnerHTML で流し込むなら、その直前に DOMPurify.sanitize() を噛ませる——という位置づけ。
学びポイント
- 「ユーザーが自由に HTML を書ける」機能は、XSS の温床になりやすいと覚えておきます
- 危険なのは
<script>だけじゃありません。onclickなどのイベント属性、javascript:スキームのリンクも要警戒 → 属性を全部落とす(attributes={})のが手堅いです
おわりに
ざっくり言うと、空港の入国審査(保存時のサーバーサニタイズ)と搭乗口の検査(表示時のクライアントサニタイズ)の関係に近いです。入国審査をちゃんとやれば、搭乗口は保険として軽くて済みます。逆に入国審査をサボって搭乗口だけで頑張ろうとすると、API を直接叩く攻撃には全く対応できません。
このバグは 「動いてはいるが、壊れている」 タイプだった。普通に表示できているし、エラーも出ません。でも仕掛けとしては明確に穴が開いています。
XSS はセキュリティの基本中の基本だと言われているけれど、自分のアプリのコードに実際に穴が開いていたと知ると、教科書で読んでいたときとは重みが違った。「うちのアプリは大丈夫」という油断こそが一番怖いというのを、身をもって学んだ感じだ。
この記事は 2026 年 4 月時点の理解で書いています(その後の実装に合わせて nh3 ベースに追記・修正済み)。サニタイザの推奨やフレームワークのデフォルト挙動は時間とともに変わるので、参考にする際は最新の情報も合わせて確認してください。