Reflected XSS in Python HTML responses

Reflected XSS in Python HTML responses

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, or Markup.
  • 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.

python
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, or django.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.

python
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.

python
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.

python
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.

python
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)
html
<h1>검색 결과</h1>
<p>{{ query }}</p>

If an HTML text fragment must be returned directly, encode it at the final boundary:

python
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