flawopen.com/xss/Python
Python의 크로스 사이트 스크립팅(XSS) (Flask, Jinja2, Django)
사용자 입력을 Markup, mark_safe로 감싸거나 render_template_string에 넘기는 f-string에 넣으면, 방문자의 글이 다른 사용자의 브라우저에서 그 사용자의 세션으로 JavaScript로 실행됩니다(CWE-79). Flask, Jinja2, Django가 자동으로 이스케이프하는 곳, 하지 않는 네 곳, 리치 텍스트를 안전하게 허용하는 방법을 정리했습니다.
보호된 입력 양식이 있는 인쇄소를 떠올려 보세요. 빈칸에 쓴 글은 일반 문장으로 인쇄됩니다. 그런데 직원이 인쇄를 시작하기 전에 고객의 메모를 프린터 지시문에 붙여 넣으면, 그 메모가 인쇄 명령이 될 수 있습니다. 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_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), 그리고 첫 번째 인자가 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를 보내며, 세션 쿠키에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