FlawOpen

flawopen.com/xss/Python

● CWE-79 · 高危
安全研究 · FlawOpen

Python 中的 XSS 跨站脚本攻击(Flask、Jinja2、Django)

把用户输入包进 Markup、mark_safe,或放进传给 render_template_string 的 f-string,会让访客的文字在其他用户的浏览器里以 JavaScript 身份运行,并带着他们的会话(CWE-79)。本文说明 Flask、Jinja2 和 Django 在哪些地方替你转义、有四个地方不会,以及如何安全地允许富文本。

💡 通俗易懂的原理解析 (ELI5)

想象一家带有安全表单的印刷店:填入空白栏的文字会作为普通文本印出。但如果店员在开印前把顾客的便条粘进打印机的指令页,便条就可能变成打印命令。传给 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
这三者都会把字符串标记为已经安全的 HTML,模板引擎便不再转义它。它们只是开发者的承诺,不是净化器:Markup(user_input) 或 mark_safe(user_input) 会把用户输入的任何内容原样发布。
render_template_string(f"...")
f-string 会在 Jinja2 解析之前就把值放进模板源码,所以自动转义永远不会把它当作变量看待。同一行代码还构成服务端模板注入:像 {{ 7*7 }} 这样的值会被 Jinja2 求值。
django.utils.html.format_html
像 str.format 一样构造 HTML,但会先用 conditional_escape 转义每个参数,再把结果标记为安全。这是 Django 推荐的在 Python 代码中构造小段 HTML 的方式,用来替代 mark_safe(f"...")。
nh3.clean()
富文本需要的是净化器,而不是转义。nh3.clean()(Rust 的 ammonia)维护标签和属性的白名单,删除 <script>、事件处理器和 javascript: URL。Bleach 已停止维护,nh3 是仍在维护的继任者。

框架映射

攻击执行流程分解

步骤 1

输入随请求到达

搜索框、个人资料表单或链接参数把 q、bio 或 next 发到服务器。攻击者可以给受害者发送精心构造的链接,或者该值被保存下来并展示给每个查看该资料的人。

步骤 2

值绕过了转义

f-string 把它放进模板源码,Markup() 或 mark_safe() 把它标记为可信,或者它落入 href,而在那里只转义 < 和 > 是不够的。无害的测试值 <img src=x onerror=alert(1)> 原封不动地通过。

步骤 3

浏览器把它解析为标记

响应中包含一个真实的 <img src=x onerror=alert(1)> 元素。如果图片资源加载失败,就会触发 onerror,链接中的 javascript: URL 在点击时执行。

步骤 4

脚本以网站的身份运行

在所示的 Flask/Jinja 和 Django 路径中,输出的标记会在应用源下运行。将不可信值放在自动转义的模板数据位置;只有经过符合上下文的净化后才允许 HTML。

源代码对比:漏洞与安全实现

存在漏洞的实现
# 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)

工程与系统安全加固清单

参考来源