This example uses the shared Flask lab.
We plug XSS in as the first web module.
Complete the Chapter III Introduction setup first. The base app.py, schema.sql, seed.sql, and database.db must already exist.
The flow is:
create modules/xss.py
register the blueprint in app.py
restart Flask
visit /xss/basic
The XSS module provides four pages:
/xss/basic
/xss/blacklist
/xss/better
/xss/fixed
Plug In The Module
From the lab directory:
cd ~/ootw-web-lab
source .venv/bin/activate
mkdir -p modules
touch modules/__init__.py
Create modules/xss.py:
cat > modules/xss.py <<'EOF'
from flask import Blueprint, redirect, render_template_string, request
from app import get_db
bp = Blueprint("xss", __name__, url_prefix="/xss")
STAGE_OWNER = {
"basic": 2101,
"blacklist": 2102,
"better": 2103,
"fixed": 2104,
}
PAGE = """
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>{{ title }}</title>
<style>
body { font-family: system-ui, sans-serif; max-width: 900px; margin: 40px auto; line-height: 1.5; }
nav a { margin-right: 12px; }
textarea { width: 100%; min-height: 90px; }
button { margin-top: 8px; }
article { border: 1px solid #ccc; padding: 12px; margin: 12px 0; }
.preview { border: 1px dashed #999; padding: 12px; min-height: 40px; }
code { background: #eee; padding: 2px 4px; }
</style>
</head>
<body>
<h1>{{ title }}</h1>
<nav>
<a href="/xss/basic">basic</a>
<a href="/xss/blacklist">blacklist</a>
<a href="/xss/better">better</a>
<a href="/xss/fixed">fixed</a>
<a href="/xss/collector">collector</a>
</nav>
<p>{{ description }}</p>
<form method="post" action="/xss/{{ stage }}/comment">
<label for="body">Comment</label>
<textarea id="body" name="body"></textarea>
<button type="submit">Save Comment</button>
</form>
{% if dom_mode %}
<h2>DOM Preview</h2>
<p>The <code>message</code> query parameter is rendered below.</p>
<div id="preview" class="preview"></div>
{% if dom_mode == "unsafe" %}
<script>
const params = new URLSearchParams(window.location.search);
document.getElementById("preview").innerHTML = params.get("message") || "";
</script>
{% else %}
<script>
const params = new URLSearchParams(window.location.search);
document.getElementById("preview").textContent = params.get("message") || "";
</script>
{% endif %}
{% endif %}
<h2>Comments</h2>
{% for comment in comments %}
<article>
{% if trusted_html %}
{{ comment["body"] | safe }}
{% else %}
{{ comment["body"] }}
{% endif %}
<footer><small>{{ comment["created_at"] }}</small></footer>
</article>
{% else %}
<p>No comments yet.</p>
{% endfor %}
</body>
</html>
"""
COLLECTOR_PAGE = """
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>XSS Collector</title>
</head>
<body>
<h1>XSS Collector</h1>
<p>This page shows data received by the local lab collector.</p>
<p><a href="/xss/basic">back to XSS lab</a></p>
{% for event in events %}
<article>
<pre>{{ event["detail"] }}</pre>
<small>{{ event["created_at"] }}</small>
</article>
{% else %}
<p>No collected events yet.</p>
{% endfor %}
</body>
</html>
"""
def comments_for(stage):
db = get_db()
return db.execute(
"""
SELECT body, created_at
FROM comments
WHERE owner_id = ?
ORDER BY id DESC
""",
(STAGE_OWNER[stage],),
).fetchall()
def save_comment(stage, body):
db = get_db()
db.execute(
"INSERT INTO comments (owner_id, body) VALUES (?, ?)",
(STAGE_OWNER[stage], body),
)
db.commit()
def render_lab(stage, title, description, trusted_html, dom_mode=None):
return render_template_string(
PAGE,
stage=stage,
title=title,
description=description,
trusted_html=trusted_html,
dom_mode=dom_mode,
comments=comments_for(stage),
)
def weak_blacklist(value):
value = value.replace("<script>", "")
value = value.replace("</script>", "")
return value
@bp.get("/")
def index():
return redirect("/xss/basic")
@bp.get("/basic")
def basic():
return render_lab(
"basic",
"XSS Basic",
"Comments are stored, then rendered as trusted HTML.",
trusted_html=True,
)
@bp.post("/basic/comment")
def basic_comment():
save_comment("basic", request.form.get("body", ""))
return redirect("/xss/basic")
@bp.get("/blacklist")
def blacklist():
return render_lab(
"blacklist",
"XSS Weak Blacklist",
"The server removes one exact spelling of the script tag, then still renders trusted HTML.",
trusted_html=True,
)
@bp.post("/blacklist/comment")
def blacklist_comment():
body = weak_blacklist(request.form.get("body", ""))
save_comment("blacklist", body)
return redirect("/xss/blacklist")
@bp.get("/better")
def better():
return render_lab(
"better",
"XSS Better Server Output",
"Stored comments are escaped, but the client-side preview still uses innerHTML.",
trusted_html=False,
dom_mode="unsafe",
)
@bp.post("/better/comment")
def better_comment():
save_comment("better", request.form.get("body", ""))
return redirect("/xss/better")
@bp.get("/fixed")
def fixed():
return render_lab(
"fixed",
"XSS Fixed",
"Stored comments are escaped and the client-side preview uses textContent.",
trusted_html=False,
dom_mode="safe",
)
@bp.post("/fixed/comment")
def fixed_comment():
save_comment("fixed", request.form.get("body", ""))
return redirect("/xss/fixed")
@bp.post("/collect")
def collect():
body = request.get_data(as_text=True)
db = get_db()
db.execute(
"""
INSERT INTO audit_log (event_type, username, detail)
VALUES (?, ?, ?)
""",
("xss-collect", "lab", body[:2000]),
)
db.commit()
return "", 204
@bp.get("/collector")
def collector():
db = get_db()
events = db.execute(
"""
SELECT detail, created_at
FROM audit_log
WHERE event_type = 'xss-collect'
ORDER BY id DESC
LIMIT 20
"""
).fetchall()
return render_template_string(COLLECTOR_PAGE, events=events)
EOF
Now register the module in app.py.
Add this near the bottom of app.py, after the route definitions and before the if __name__ == "__main__": block:
from modules.xss import bp as xss_bp
app.register_blueprint(xss_bp)
Run the app:
flask --app app run --host 127.0.0.1 --port 5000
Confirm the module is live:
curl -i "http://127.0.0.1:5000/xss/basic"
Basic Vulnerability
Open:
http://127.0.0.1:5000/xss/basic
This page stores comments and renders them as trusted HTML:
{{ comment["body"] | safe }}
Normal comment:
curl -i -X POST "http://127.0.0.1:5000/xss/basic/comment" \
-d "body=hello from operator"
Exploit:
curl -i -X POST "http://127.0.0.1:5000/xss/basic/comment" \
--data-urlencode "body=<script>alert(document.domain)</script>"
Refresh:
http://127.0.0.1:5000/xss/basic
The payload runs every time the comment list renders.
This is stored XSS.

Basic Protection - Weak Blacklist
Open:
http://127.0.0.1:5000/xss/blacklist
This page removes one exact spelling of one dangerous tag:
value = value.replace("<script>", "")
value = value.replace("</script>", "")
That damages this payload:
<script>alert(1)</script>
It does not make the page safe.
Bypass with an event handler:
curl -i -X POST "http://127.0.0.1:5000/xss/blacklist/comment" \
--data-urlencode "body=<img src=x onerror=alert(document.domain)>"
Refresh:
http://127.0.0.1:5000/xss/blacklist
The payload runs because the browser accepts many executable HTML patterns. Blocking one string is not a security boundary.

Better Protection - Escaped Server Output
Open:
http://127.0.0.1:5000/xss/better
This page renders stored comments as text:
{{ comment["body"] }}
Jinja escapes HTML by default. The browser displays the payload instead of executing it.
Test it:
curl -i -X POST "http://127.0.0.1:5000/xss/better/comment" \
--data-urlencode "body=<img src=x onerror=alert(document.domain)>"
Refresh:
http://127.0.0.1:5000/xss/better
The stored comment no longer executes.
However, this page still has a DOM sink:
document.getElementById("preview").innerHTML = params.get("message") || "";
Exploit the message parameter:
http://127.0.0.1:5000/xss/better?message=%3Cimg%20src%3Dx%20onerror%3Dalert(document.domain)%3E
The server-side output is better, but the client-side code is still unsafe.

Fixed Version
Open:
http://127.0.0.1:5000/xss/fixed
The fixed page does two things:
- Stored comments are rendered as text.
- The DOM preview uses
textContent.
The safe sink:
document.getElementById("preview").textContent = params.get("message") || "";
Run the same DOM payload:
http://127.0.0.1:5000/xss/fixed?message=%3Cimg%20src%3Dx%20onerror%3Dalert(document.domain)%3E
The browser displays the payload as text. It does not execute.
Local Impact Proof
Alert boxes prove execution, but they do not prove useful impact.
Use the local collector to prove same-origin JavaScript capability.
Submit this to /xss/basic/comment:
curl -i -X POST "http://127.0.0.1:5000/xss/basic/comment" \
--data-urlencode "body=<script>fetch('/health').then(r => r.text()).then(t => navigator.sendBeacon('/xss/collect', t))</script>"
Open:
http://127.0.0.1:5000/xss/basic
Then view:
http://127.0.0.1:5000/xss/collector
The collector should contain the /health response.
This proves that JavaScript executed in the application's origin and made a same-origin request from the browser context.
If cookies are marked HttpOnly, document.cookie will not expose them. That is the point. XSS impact is broader than cookie theft.
Remediation
Patch XSS by controlling output:
- Escape untrusted data in HTML text.
- Escape attributes and URLs according to their context.
- Keep untrusted data out of inline JavaScript.
- Use
textContentinstead ofinnerHTML. - Sanitize rich text with an allowlist sanitizer.
- Set session cookies with
HttpOnly,Secure, andSameSite. - Add a strict CSP after output handling is correct.
Input validation helps keep data clean. It does not replace output encoding.
Detection
Useful signals include:
- Requests containing
<script,<img,<svg, or event handlers. - Stored records containing HTML tags in fields that should be plain text.
- Admin pages triggering unexpected calls to collector-like endpoints.
- CSP violation reports.
- Repeated encoded payloads in comments, search fields, profile fields, or support tickets.
- User agents loading unexpected external scripts.
Useful payload indicators:
<script
onerror=
onload=
javascript:
document.cookie
localStorage
innerHTML
eval(
%3Cscript
Detection is not the fix. It tells us where users are being probed and which controls failed.