Description
Request-derived values included in an HTML response need encoding for their exact output context. HTML text, quoted attributes, URLs, JavaScript, and CSS have different requirements. Missing or inappropriate encoding may cause the browser to interpret attacker-controlled input as markup or code, resulting in reflected cross-site scripting (XSS).
Framework response defaults also matter:
| Framework/API | Default behavior | HTML output |
|---|---|---|
Django HttpResponse |
text/html unless configured otherwise |
Default constructor or content_type="text/html" |
| String returned by a Flask view | Converted to a text/html response |
String return, make_response, or Response |
| Ordinary FastAPI return value | Usually serialized as JSON | HTMLResponse, media_type="text/html", or route response_class=HTMLResponse |
| Starlette response | Endpoint returns a Response object |
HTMLResponse or a Response with media_type="text/html" |
A plain string returned by FastAPI is not necessarily HTML. Conversely, Django and Flask responses can be parsed as HTML when their Content-Type is HTML, even if the body looks like JSON.
Potential impact
- Session or user-data theft.
- Requests made with the victim's privileges and altered page content.
- Phishing through forged login or payment interfaces.
- Collection of keystrokes or sensitive page information.
Remediation
Prefer templates and safe response types
- Render ordinary HTML text and quoted attributes using Django or Jinja templates with autoescaping enabled.
- Do not mark untrusted values as trusted with
safe,mark_safe, orMarkup. - If HTML is unnecessary, use the framework's JSON or plain-text response type. A JSON-looking string does not ensure a safe content type.
FastAPI's ordinary return values or JSONResponse suit JSON; use PlainTextResponse for plain text.
from fastapi import FastAPI
from starlette.responses import PlainTextResponse
app = FastAPI()
@app.get("/search")
async def search(q: str):
return {"query": q} # JSON response
@app.get("/preview", response_class=PlainTextResponse)
async def preview(q: str):
return q
Encode for the output context
When constructing HTML directly, encode the entire untrusted value at its final output boundary.
- HTML text and quoted ordinary attributes: Use
html.escape(value, quote=True),markupsafe.escape, ordjango.utils.html.escape. - URL attributes: Validate schemes and destinations, construct the URL with an appropriate API, then apply HTML attribute encoding.
- JavaScript data: Use safe JSON serialization and the framework's script-data facilities instead of assembling executable code as strings.
- CSS: Do not construct styles or selectors directly from untrusted values.
HTML text encoding is not a universal sanitizer for JavaScript, CSS, comments, tag names, URLs, or unquoted attributes. In particular, html.escape(..., quote=False) does not protect quoted attributes adequately.
Use a maintained sanitizer for rich HTML
If users must author HTML, use a sanitizer with an explicit allow-list instead of ordinary output encoding.
- On a Python server, configure permitted tags, attributes, and URL schemes explicitly in the maintained
nh3.Cleaner. - At the browser rendering boundary, use an up-to-date DOMPurify with a reviewed policy.
- Do not modify sanitized output through further concatenation or library processing, which can invalidate its protections.
- Mozilla ended Bleach's security maintenance on 2026-06-05. Use a maintained alternative for new code and migrate existing use.
This minimal server-side example allows limited rich HTML. Narrow the policy further to match the application and its threats.
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)
Consider the native Sanitizer API's Element.setHTML() only after confirming support in the target browsers; it is not yet Baseline. setHTMLUnsafe() is not a general security fix.
Examples
Before
Flask turns returned strings into HTML responses by default, so directly inserting request data is unsafe.
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 strings require JavaScript-aware handling. HTML text encoding does not handle backslashes or line breaks as JavaScript string content.
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>"
)
After
Pass values as template variables and retain HTML template autoescaping.
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>
If an HTML text fragment must be returned directly, encode it at the final boundary:
import html
from django.http import HttpResponse
def preview(request):
value = request.GET.get("value", "")
return HttpResponse(html.escape(value, quote=True))
Usage considerations
Check the actual response Content-Type and the final insertion context. Values written through templates, persistent storage, or browser DOM operations also need appropriate handling at each output point.
References
- 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 template autoescaping
- Flask response behavior
- Flask Cross-Site Scripting security guidance
- FastAPI custom responses
- Starlette Responses
- Python
html.escape - MarkupSafe Escaping
nh3documentation- Bleach end-of-maintenance notice
- DOMPurify Security Goals and Threat Model
- MDN
Element.setHTML()browser support