flawopen.com/xss/Python
Python のクロスサイトスクリプティング(XSS)対策(Flask・Jinja2・Django)
ユーザー入力を Markup や mark_safe で包んだり、render_template_string に渡す f-string に入れたりすると、訪問者の文章が他のユーザーのブラウザーで、そのユーザーのセッションのまま JavaScript として実行されます(CWE-79)。Flask・Jinja2・Django が自動でエスケープする箇所、しない 4 つの箇所、リッチテキストを安全に許可する方法を解説します。
保護された入力欄のある印刷所を想像してください。空欄に書かれた文字は普通の文字として印刷されます。しかし、印刷前に店員が客のメモをプリンターの指示書へ貼り付けると、そのメモが印刷命令になり得ます。render_template_string に渡す Python の f-string は同じことをします。入力を Jinja2 のソースへ埋め込み、autoescape が変数として処理する前に解釈対象へ入れてしまいます。Markup と mark_safe は「確認済み HTML」のスタンプのようなものですが、内容を確認しません。入力はテンプレート変数のままにして autoescape に任せ、小さな Django の断片には format_html を使います。
主要な概念と専門用語
Jinja2 autoescape- Flask は
.html、.htm、.xml、.xhtmlのテンプレートとrender_template_stringで Jinja2 の自動エスケープを有効にするため、{{ q }}は<img ...>になります。単独で作ったjinja2.Environment()は、autoescape=select_autoescape()を渡さない限り自動エスケープが無効です。 Markup / |safe / mark_safe- 3 つとも文字列を安全な HTML としてマークし、テンプレートエンジンはそれをエスケープしなくなります。これらは開発者による約束であってサニタイザーではありません。
Markup(user_input)やmark_safe(user_input)は、ユーザーが入力したものをそのまま公開します。 render_template_string(f"...")- f-string は Jinja2 が解析する前に値をテンプレートのソースに入れてしまうため、自動エスケープはそれを変数として扱いません。同じ行はサーバーサイドテンプレートインジェクションでもあり、
{{ 7*7 }}のような値は Jinja2 が評価します。 django.utils.html.format_htmlstr.formatのように HTML を組み立てますが、先に各引数をconditional_escapeでエスケープしてから結果を安全とマークします。mark_safe(f"...")の代わりに、Python コードで小さな HTML 断片を作る Django 推奨の方法です。nh3.clean()- リッチテキストに必要なのはエスケープではなくサニタイザーです。
nh3.clean()(Rust の ammonia)はタグと属性の許可リストを持ち、<script>、イベントハンドラー、javascript:URL を取り除きます。Bleach は非推奨となり、nh3 が保守されている後継です。
フレームワーク対応
- このガイドで説明する弱点の CWE 参照: CWE-79
- OWASP Top 10:2025 — A05:2025 Injection
ステップ・バイ・ステップの攻撃フロー
入力がリクエストで届く
検索ボックス、プロフィールフォーム、リンクのパラメータが q、bio、next をサーバーに送ります。被害者に細工したリンクを送ることも、値が保存されてプロフィールを見る全員に表示されることもあります。
値がエスケープをすり抜ける
f-string がそれをテンプレートのソースに入れるか、Markup() や mark_safe() が信頼済みとマークするか、< と > のエスケープだけでは足りない href に入ります。無害なテスト値 <img src=x onerror=alert(1)> がそのまま通ります。
ブラウザーがそれをマークアップとして解析する
レスポンスには本物の要素として <img src=x onerror=alert(1)> が含まれます。画像リソースの読み込みに失敗すると onerror が発火し、リンク内の javascript: URL はクリック時に実行されます。
スクリプトがサイトとして実行される
示された Flask/Jinja と Django の経路では、出力されたマークアップがアプリのオリジンで実行されます。信頼できない値は自動エスケープされるテンプレートデータ位置に置き、HTML を許可するのは文脈に合ったサニタイズ後に限ります。
ソースコード比較:脆弱 vs 堅牢化
# views.py: 以下のハンドラーはどれもリクエストの文字列を HTML としてページに入れてしまう
from django.utils.safestring import mark_safe
from flask import Flask, render_template_string, request
from markupsafe import Markup
app = Flask(__name__)
# f-string がテンプレートのソースを作るため、Jinja2 は q をエスケープしない
@app.get("/search")
def search():
q = request.args.get("q", "")
return render_template_string(f"<h1>Results for {q}</h1>")
# Markup() が Jinja2 に bio は信頼できる HTML だと伝えてしまう
@app.get("/profile")
def profile():
bio = request.args.get("bio", "")
return render_template_string("<div class='bio'>{{ bio }}</div>", bio=Markup(bio))
# f-string に mark_safe を使うと name が HTML として公開される
def greeting(name):
return mark_safe(f"<p>Hello, {name}</p>")
# 自動エスケープでは href の javascript: URL は止まらない
@app.get("/continue")
def continue_link():
url = request.args.get("next", "/")
return render_template_string('<a href="{{ url }}">Continue</a>', url=url)
# views.py: 値はテンプレート変数のまま。リッチテキストはサニタイズする
import nh3
from django.utils.html import format_html
from flask import Flask, render_template_string, request
app = Flask(__name__)
# q はテンプレート変数なので自動エスケープでエンコードされる
@app.get("/search")
def search():
q = request.args.get("q", "")
return render_template_string("<h1>Results for {{ q }}</h1>", q=q)
# HTML を信頼する前に nh3 がタグの許可リストを適用する
@app.get("/profile")
def profile():
bio = request.args.get("bio", "")
return render_template_string("<div class='bio'>{{ bio|safe }}</div>", bio=nh3.clean(bio))
# format_html は結果を安全とマークする前に各引数をエスケープする
def greeting(name):
return format_html("<p>Hello, {}</p>", name)
# リンク先には自サイトのパスだけを許可する
@app.get("/continue")
def continue_link():
url = request.args.get("next", "/")
if not url.startswith("/") or url.startswith("//"):
url = "/"
return render_template_string('<a href="{{ url }}">Continue</a>', url=url)
エンジニアリング&システム堅牢化チェックリスト
- ✓Bandit B703/B308(
mark_safe)と B704(リテラル以外へのMarkup)、そして第 1 引数が f-string、%、.format()の結果であるrender_template_stringで CI を失敗させる。 - ✓値は変数としてテンプレートに渡し、自動エスケープに任せる。Jinja2 を単独で使う場合は
Environment(autoescape=select_autoescape())を作成する。 - ✓小さな断片には
mark_safe()ではなくformat_html()を使い、ユーザーが提供する HTML はすべてnh3.clean()に通す。 - ✓ユーザーが制御する URL は
hrefやsrcに入る前にすべて検証する。単一の/で始まる自サイトのパスか、明示的な http/https の許可リストだけを受け付ける。 - ✓nonce ベースの
script-srcを持ち'unsafe-inline'を含まない Content-Security-Policy を送り、セッション cookie にはHttpOnlyとSameSite=Laxを付ける。
参考資料
- CWE-79: Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting')
- Flask — Security Considerations: Cross-Site Scripting
- Django — Security: Cross site scripting (XSS) protection
- Django — format_html
- nh3 — Python bindings to the ammonia HTML sanitizer
- OWASP Cross Site Scripting Prevention Cheat Sheet
- nh3 — Python bindings to the ammonia HTML sanitizer