PythonのHTMLレスポンスにおける反射型XSS

PythonのHTMLレスポンスにおける反射型XSS

説明

リクエスト由来の値をHTMLレスポンスに含める場合は、出力先に合ったエンコーディングが必要です。HTMLテキスト、引用符付き属性、URL、JavaScript、CSSでは、安全に扱うための条件が異なります。適切にエンコードしないと、ブラウザーが攻撃者の入力をマークアップやコードとして解釈し、反射型クロスサイトスクリプティング(XSS)が発生するおそれがあります。

フレームワークの標準のレスポンス動作も区別してください。

フレームワーク/API 標準の動作 HTMLを出力する方法
Django HttpResponse 別途指定しなければ text/html 標準のコンストラクター、または content_type="text/html"
Flaskビューの文字列の戻り値 text/html レスポンスに変換 文字列の戻り値、make_response、Response
FastAPIの通常の戻り値 通常はJSONにシリアライズ HTMLResponse、media_type="text/html"、ルートの response_class=HTMLResponse
Starletteのレスポンス エンドポイントが Response オブジェクトを返す HTMLResponse、または media_type="text/html" の Response

FastAPIで文字列を返すだけでは、必ずしもHTMLレスポンスにはなりません。一方、DjangoやFlaskでは、本文がJSONのように見えても、Content-Type がHTMLならブラウザーのHTML解析対象になる場合があります。

想定される影響

  • セッションやユーザーデータの窃取
  • 被害者の権限でのリクエスト送信や画面内容の改ざん
  • 偽のログイン・決済画面によるフィッシング
  • キー入力や機密性の高いページ情報の収集

対処方法

テンプレートと適切なレスポンス形式を優先する

  • 通常のHTMLテキストや引用符付き属性は、自動エスケープを有効にしたDjangoまたはJinjaテンプレートで描画します。
  • 信頼できない値に safe、mark_safe、Markup を使い、安全な値として扱わないでください。
  • HTMLが不要なら、フレームワークのJSONまたはプレーンテキスト用レスポンスを使います。本文をJSON風の文字列にするだけでは、安全なコンテンツタイプは保証されません。

JSONにはFastAPIの通常の戻り値や JSONResponse、プレーンテキストには PlainTextResponse を使えます。

python
from fastapi import FastAPI
from starlette.responses import PlainTextResponse

app = FastAPI()

@app.get("/search")
async def search(q: str):
    return {"query": q}  # JSONレスポンス

@app.get("/preview", response_class=PlainTextResponse)
async def preview(q: str):
    return q

出力先に合わせてエンコードする

HTMLを直接組み立てる場合は、最終的な出力時に信頼できない値全体をエンコードします。

  • HTMLテキストと引用符付きの通常の属性: html.escape(value, quote=True)、markupsafe.escape、django.utils.html.escape を使います。
  • URL属性: スキームと送信先を検証し、URL構築APIで組み立てた後にHTML属性エンコーディングを適用します。
  • JavaScriptのデータ: 実行コードを文字列で組み立てず、安全なJSONシリアライズとフレームワークのスクリプトデータ機能を使います。
  • CSS: 信頼できない値からスタイルやセレクターを直接作らないでください。

HTMLテキスト用のエンコーディングは、JavaScript、CSS、コメント、タグ名、URL、引用符なしの属性に使える万能なサニタイザーではありません。特に html.escape(..., quote=False) では、引用符付き属性を適切に保護できません。

書式付きHTMLには保守されているサニタイザーを使う

ユーザーがHTMLを作成する必要がある場合は、通常の出力エンコーディングではなく、明示的な許可リストを持つサニタイザーを使います。

  • Pythonサーバーでは、保守されている nh3.Cleaner に許可するタグ、属性、URLスキームを明示します。
  • ブラウザーで描画する直前に処理する場合は、最新のDOMPurifyと検証済みのポリシーを使います。
  • サニタイズ済みの結果を後から文字列結合や別のライブラリーで変更すると、安全性が失われる場合があります。そのまま最終出力に使ってください。
  • Mozillaは2026-06-05にBleachのセキュリティ保守を終了しました。新規導入は避け、既存の利用も保守されている代替手段へ移行してください。

次は、Pythonサーバーで限定的な書式付きHTMLを許可する最小限の例です。実際の許可リストは、アプリケーションの要件と脅威に合わせてさらに絞り込んでください。

python
import nh3

rich_html_cleaner = nh3.Cleaner(
    tags={"p", "strong", "em", "a"},
    attributes={"a": {"href", "title"}},
    url_schemes={"https"},
)

safe_html = rich_html_cleaner.clean(user_html)

ネイティブのSanitizer APIの Element.setHTML() は、対象ブラウザーの対応を確認できる場合に限って検討してください。まだBaseline機能ではありません。setHTMLUnsafe() は一般的なセキュリティ修正として推奨しません。

例

変更前

Flaskは文字列の戻り値を標準でHTMLレスポンスにするため、リクエストの値を直接埋め込むと危険です。

python
from flask import Flask, request

app = Flask(__name__)

@app.get("/search")
def search():
    query = request.args.get("q", "")
    return f"<h1>검색 결과</h1><p>{query}</p>"

JavaScript文字列には、その文法に合った処理が必要です。HTMLテキストのエンコーディングは、バックスラッシュや改行をJavaScript文字列用に処理するものではありません。

python
import html

from fastapi import FastAPI
from fastapi.responses import HTMLResponse

app = FastAPI()

@app.get("/search")
async def search(q: str):
    return HTMLResponse(
        f"<script>const query = '{html.escape(q)}';</script>"
    )

変更後

値はテンプレート変数として渡し、HTMLテンプレートの自動エスケープを維持します。

python
from flask import Flask, render_template, request

app = Flask(__name__)

@app.get("/search")
def search():
    query = request.args.get("q", "")
    return render_template("search.html", query=query)
html
<h1>검색 결과</h1>
<p>{{ query }}</p>

HTMLテキストの断片を直接返す必要がある場合は、最終出力時にエンコードします。

python
import html

from django.http import HttpResponse

def preview(request):
    value = request.GET.get("value", "")
    return HttpResponse(html.escape(value, quote=True))

適用時の注意点

実際のレスポンスの Content-Type と、値が最終的に挿入される位置を確認してください。テンプレート、保存済みデータ、ブラウザーDOMへ出力する値も、それぞれの出力箇所で適切に処理する必要があります。

参考資料