설명
요청에서 유래한 값이 HTML 응답 본문에 포함될 때는 값이 놓이는 정확한 출력 컨텍스트에 맞는 인코딩이 필요합니다. HTML 텍스트, 따옴표로 감싼 속성, URL, JavaScript, CSS에는 서로 다른 안전 조건이 있습니다. 잘못된 인코딩을 사용하거나 인코딩 없이 값을 반영하면 브라우저가 공격자 제어 입력을 마크업이나 코드로 해석하여 반사형 크로스 사이트 스크립팅(Reflected 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 HttpResponse나 Flask 문자열 반환은 본문이 JSON처럼 보이더라도 실제 Content-Type이 HTML이면 브라우저의 HTML 파싱 대상이 될 수 있습니다.
잠재적 영향
- 세션 또는 사용자 데이터 탈취
- 피해자 권한으로 요청 실행 및 화면 내용 변조
- 위조 로그인·결제 화면을 이용한 피싱
- 키 입력이나 민감한 페이지 정보 수집
해결 방법
템플릿과 안전한 응답 형식을 우선 사용
- 일반 HTML 텍스트와 따옴표로 감싼 속성은 자동 이스케이프가 활성화된 Django 또는 Jinja 템플릿으로 렌더링합니다.
- 신뢰할 수 없는 값을
safe,mark_safe,Markup등으로 안전한 값처럼 표시하지 않습니다. - HTML이 필요하지 않으면 프레임워크의 실제 JSON 또는 일반 텍스트 응답을 사용합니다. 본문을 JSON 문자열처럼 만드는 것만으로는 안전한 콘텐츠 유형이 보장되지 않습니다.
예를 들어 FastAPI의 일반 반환값이나 JSONResponse는 JSON에 적합하고, 일반 텍스트에는 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, HTML 주석, 태그 이름, URL, 따옴표 없는 속성을 안전하게 만드는 범용 새니타이저가 아닙니다. 특히 html.escape(..., quote=False)는 따옴표 속성의 안전 조건을 충족하지 않습니다.
서식 있는 HTML은 유지 관리되는 새니타이저로 처리
사용자가 HTML 마크업을 작성해야 한다면 단순 출력 인코딩이 아니라 명시적인 허용 목록 정책을 가진 HTML 새니타이저가 필요합니다.
- Python 서버에서 처리할 때는 유지 관리되는
nh3.Cleaner에 허용 태그, 속성, URL 스킴을 명시합니다. - 브라우저 렌더링 경계에서 처리할 때는 최신 DOMPurify와 검토된 정책을 사용합니다.
- 정화된 결과를 이후에 문자열 결합이나 라이브러리 처리로 변경하면 안전성이 깨질 수 있으므로 그대로 최종 출력합니다.
- Mozilla는 2026-06-05에 Bleach의 보안 유지 관리를 종료했습니다. 새 코드에 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: Improper Neutralization of Input During Web Page Generation
- 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 Cross-Site Scripting 보안 지침
- FastAPI 사용자 정의 응답
- Starlette Responses
- Python
html.escape - MarkupSafe Escaping
nh3문서- Bleach 유지 관리 종료 공지
- DOMPurify Security Goals and Threat Model
- MDN
Element.setHTML()지원 상태