説明
リクエスト由来の値を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 を使えます。
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を許可する最小限の例です。実際の許可リストは、アプリケーションの要件と脅威に合わせてさらに絞り込んでください。
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レスポンスにするため、リクエストの値を直接埋め込むと危険です。
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文字列用に処理するものではありません。
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テンプレートの自動エスケープを維持します。
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)
<h1>검색 결과</h1>
<p>{{ query }}</p>
HTMLテキストの断片を直接返す必要がある場合は、最終出力時にエンコードします。
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へ出力する値も、それぞれの出力箇所で適切に処理する必要があります。
参考資料
- CWE-79: Webページ生成時の入力の不適切な無害化
- OWASP Cross Site Scripting Prevention Cheat Sheet
- OWASP Top 10:2025 A05 - Injection
- OWASP Top 10:2021 A03 - Injection
- OWASP ASVS 5.0 V1: Encoding and Sanitization
- Django
HttpResponse - Djangoテンプレートの自動エスケープ
- Flaskのレスポンス動作
- FlaskのXSS対策ガイド
- FastAPIのカスタムレスポンス
- Starlette Responses
- Python
html.escape - MarkupSafe Escaping
nh3ドキュメント- Bleachの保守終了告知
- DOMPurify Security Goals and Threat Model
- MDN
Element.setHTML()の対応状況