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로 이스케이프한 뒤 결과를 안전하다고 표시합니다. mark_safe(f"...") 대신 Python 코드에서 작은 HTML 조각을 만드는 Django의 권장 방식입니다.
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을 허용하세요.

소스 코드 비교: 취약한 구현 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)

엔지니어링 및 시스템 보안 강화 체크리스트

출처