紡Book の勉強ノートに戻る
コードレビュー学習2026.04.01

ユーザー入力 HTML の XSS をなんとかする

コードレビューセキュリティXSS

コードレビューで見つかった問題を、勉強のためにまとめたものです(修正済み)。


この記事でわかること

  • 自分のコードに潜んでいた XSS というクリティカルな問題
  • 危険なのは <script> だけじゃない、という属性レベル・スキームレベルの視点
  • 「保存する入口」で無害化しておくのがなぜ正しいか(出口=表示する場所は後から増える)
  • 正しい直し方と、今後同じミスをしないための教訓

修正前のコード

ユーザーが自由に HTML を入力できるフィールドがあり、その内容を サニタイズせずそのまま保存・返却 していた。

python
# モデル(修正前のイメージ。実体は 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 の怖さは「保存したものが、後からどこに表示されるか分からない」ところにあります。

置き場所は2つ。届いた本文はメモリ、保存後は DB。出口は描画で保存しない。届いた本文HTTP の bodyメモリ・保存しないタグも属性もそのまま入口サーバ DBAlbumCustomPagehtml_content閉じても残る今今の出口・本人だけ生 HTML では描かない画面・保存しない後後から増える出口・共有DB の中身が他人に出る画面・保存しない出口が増えても読む先は同じ html_content である。nh3.clean は関所で置き場所ではない。入口を通さずに書いた中身は出口では落とせない。

対策

大前提: クライアント側だけのサニタイズはダメ。攻撃者は API を直接叩けばフロントの JS を完全にバイパスできるから。

クライアントの無害化は飛ばせる。DB に残るのはサーバ側を通した値。ブラウザの入力メモリ・保存しない任意の無害化を通れる攻撃者の直接 POSTメモリ・保存しないフロントを通らない飛ばす届いた本文サーバ到着時メモリ・保存しない入口サーバ DBAlbumCustomPagehtml_content閉じても残るnh3.clean は関所で、置き場所ではない。クライアントの無害化も関所である。ここを抜くと、届いた本文がそのまま DB に残る。

サーバーサイド: nh3 で保存前に無害化する

DRF の validate_<フィールド名> メソッドにサニタイズを通します。validate_html_content を書いておくと、is_valid() のタイミングで自動的に呼ばれ、戻り値が保存値に差し替わります。

python
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 ベースに追記・修正済み)。サニタイザの推奨やフレームワークのデフォルト挙動は時間とともに変わるので、参考にする際は最新の情報も合わせて確認してください。