Keep Django's XFrameOptionsMiddleware (X-Frame-Options defaults to DENY) and add a CSP with ContentSecurityPolicyMiddleware and SECURE_CSP = {"frame-ancestors": [CSP.NONE]}, which browsers honor over X-Frame-Options. Exempt only pages meant to be embedded with xframe_options_exempt and csp_override({}). Never rely on a <meta> CSP or a JavaScript frame buster.
- Confirm django.middleware.clickjacking.XFrameOptionsMiddleware is in MIDDLEWARE (startproject adds it) and leave X_FRAME_OPTIONS at its DENY default, or set SAMEORIGIN if you frame your own pages.
- Add django.middleware.csp.ContentSecurityPolicyMiddleware (Django 6.0+) and set SECURE_CSP with a frame-ancestors directive, e.g. {"frame-ancestors": [CSP.NONE]} (import CSP from django.utils.csp), so modern browsers refuse framing.
- For the rare page that must be embeddable, decorate the view with xframe_options_exempt (from django.views.decorators.clickjacking) and csp_override({}) (from django.views.decorators.csp) to drop both headers for that view only.
- Remove any JavaScript frame buster and any <meta http-equiv="Content-Security-Policy"> you relied on for framing; browsers do not enforce either.
- Add regression tests that read the response headers and assert framing is refused on the sensitive pages and allowed on the embeddable one.
The three implementations share one API and one test suite; only the code below changes. The tests load the one that IMPL names.
The vulnerable code
# No XFrameOptionsMiddleware and no ContentSecurityPolicyMiddleware: no framing header
# is sent. X_FRAME_OPTIONS is set but unused, because the middleware that reads it is gone.
MIDDLEWARE_EXTRA: list[str] = []
X_FRAME_OPTIONS = "DENY"
SECURE_CSP: dict = {}
def badge(request) -> HttpResponse:
return HttpResponse(content.BADGE, content_type="text/html")With IMPL=vulnerable, 3 of 6 tests fail (3 exploit tests).
The fix: Django's middleware plus a per-view exemption
MIDDLEWARE_EXTRA = [
"django.middleware.csp.ContentSecurityPolicyMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
]
X_FRAME_OPTIONS = "DENY"
SECURE_CSP = {"frame-ancestors": [CSP.NONE]}
@xframe_options_exempt
@csp_override({})
def badge(request) -> HttpResponse:
# xframe_options_exempt tells the middleware to leave X-Frame-Options off this view,
# and csp_override({}) removes the CSP header, so the public badge is embeddable.
return HttpResponse(content.BADGE, content_type="text/html")With IMPL=fixed, 6 of 6 tests pass.
A fix that looks safe but is not
class AllowFromMiddleware:
"""Sets the dead-letter ALLOW-FROM value on every page but the public badge."""
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
response = self.get_response(request)
if request.path != "/embed/badge":
response.headers["X-Frame-Options"] = "ALLOW-FROM https://partner.example"
return response
MIDDLEWARE_EXTRA = ["pages.impl.wrong.AllowFromMiddleware"]
X_FRAME_OPTIONS = "DENY"
SECURE_CSP: dict = {}
def badge(request) -> HttpResponse:
return HttpResponse(content.BADGE, content_type="text/html")With IMPL=wrong, 4 of 6 tests fail (4 exploit tests).
The exploit tests send the attack and check that it fails; functional tests next to them keep ordinary requests working, so a fix that rejects everything fails too.
# Exploit: a page an attacker frames to trick a signed-in user into clicking.
def test_exploit_dashboard_cannot_be_framed_by_other_sites(client):
assert frames_blocked(client.get("/").headers), "the dashboard can be framed and clickjacked"
def test_exploit_sensitive_page_refuses_all_framing(client):
# The account-close confirmation is the highest-value clickjacking target, so it must
# refuse framing outright: CSP frame-ancestors 'none' (which also covers same origin).
headers = client.get("/account/close").headers
assert frames_blocked(headers), "the account-close page can be framed and clickjacked"
assert frame_ancestors(headers.get("Content-Security-Policy")) == ["'none'"]
def test_exploit_allow_from_is_not_a_defense(client):
# If X-Frame-Options is sent at all, it must be an implemented value. ALLOW-FROM and
# ALLOWALL are ignored by browsers, so a page relying on them is framable by anyone.
for path in PROTECTED:
xfo = client.get(path).headers.get("X-Frame-Options")
if xfo is not None:
assert xfo.strip().upper() in ("DENY", "SAMEORIGIN"), f"{path}: X-Frame-Options {xfo!r} is ignored by browsers"
def test_exploit_meta_csp_is_not_a_defense(client):
# The page's <meta> Content-Security-Policy is ignored for frame-ancestors, so the
# protection must be present in the HTTP header, not only in the markup.
for path in PROTECTED:
assert frames_blocked(client.get(path).headers), f"{path} relies on an in-page <meta> tag, which browsers ignore"- Removing XFrameOptionsMiddleware (or decorating a view with xframe_options_exempt by mistake) silently drops the protection; the default is only in place while the middleware is.
- frame-ancestors in a <meta http-equiv="Content-Security-Policy"> tag is ignored by browsers: CSP Level 3 says the directive works only as an HTTP header, which is what ContentSecurityPolicyMiddleware sends.
- X-Frame-Options: ALLOW-FROM is a dead letter; Chrome and Safari never implemented it and the HTML Standard says it is not to be implemented, so a browser ignores it and allows framing. Use frame-ancestors for an allowlist instead.
- When both headers are present, a browser that understands CSP obeys frame-ancestors and ignores X-Frame-Options (HTML Standard), so keep SECURE_CSP and X_FRAME_OPTIONS consistent.
- ContentSecurityPolicyMiddleware and XFrameOptionsMiddleware only set a header that is not already present, so a view or upstream proxy that sets its own header wins; csp_override and xframe_options_exempt are the supported ways to vary it per view.
- SECURE_CSP_REPORT_ONLY (Content-Security-Policy-Report-Only) does not block framing; it only reports. Put frame-ancestors in SECURE_CSP to enforce it.
- X-Frame-Options: SAMEORIGIN checks every ancestor up the frame chain, not just the top document.
- Run the tests in all three modes: IMPL=fixed must pass; IMPL=vulnerable and IMPL=wrong must fail (node ../../run-all.mjs clickjacking/python-django does this for you).
- Request a protected page and confirm the response carries Content-Security-Policy: frame-ancestors 'none' and X-Frame-Options: DENY; request the embeddable badge and confirm it carries neither.
- Frame the page in a current browser from another origin: a protected page is blocked and the badge still shows. No scanner checked here detects the missing header, so the tests are the control.
What each rule reported when it was run on this card's code, or what its source shows it covers where it was not run. A rule that exists for the weakness does not always catch it.
| Tool | Rule | What it found here |
|---|---|---|
| Semgrep | p/python, p/owasp-top-ten, p/security-audit, p/django | No findings on any implementation. semgrep-rules develop at 9bbf021 has no Python rule about X-Frame-Options, frame-ancestors, XFrameOptionsMiddleware or X_FRAME_OPTIONS, so a missing or invalid framing header is invisible to it.Checked: Semgrep 1.180.0 (CE) in semgrep/semgrep:1.180.0@sha256:529ee8a277ec8adc5b534d7c74eea0a47e9de21d62852b6ba7ac6ba9566845c3; semgrep scan --metrics=off --config p/python --config p/owasp-top-ten --config p/security-audit --config p/django --exclude tests . |
| Bandit | all plugins (default profile)Bandit 1.9.4 | No findings in any implementation. Bandit has no plugin for anti-framing headers, so its silence says nothing about this weakness.Checked: Bandit 1.9.4 (pip install bandit==1.9.4 in a scratch venv, Python 3.14.8); bandit -r . -x ./.venv,./tests |
| Component | Version |
|---|---|
| Language | Python |
| Runtime | CPython 3.14.8 |
| Framework | Django 6.1.2 (XFrameOptionsMiddleware, ContentSecurityPolicyMiddleware) |
| django | 6.1.2 |
| pytest | 9.1.1 |
| pytest-django | 4.14.0 |
Download the project, install its dependencies once (this step needs the network), then run the tests against each implementation from the clickjacking/python-djangofolder.
python3 -m venv .venv
.venv/bin/python -m pip install -r requirements.txt# Expected: every test passes
IMPL=fixed .venv/bin/python -m pytest
# Expected: an exploit test fails
IMPL=vulnerable .venv/bin/python -m pytest
# Expected: a test fails
IMPL=wrong .venv/bin/python -m pytestEach run writes a JUnit report to reports/junit.xml. From the top folder of the download, node run-all.mjs clickjacking/python-django runs all three modes and checks the expected results.
Verified
- Machine
- macOS 27.0 (Darwin 27.0.0, arm64)
- Toolchain
- python 3.14.8
- pip 26.2.1
- django 6.1.2
- pytest 9.1.1
- pytest-django 4.14.0
- Matrix
- fixed: 6 of 6 tests pass; vulnerable: 3 of 6 tests fail (3 exploit tests); wrong: 4 of 6 tests fail (4 exploit tests)
- Test results
- IMPL=fixed 6/6 pass; IMPL=vulnerable 3/6 fail, all exploit (dashboard framable, sensitive page framable, protection only in <meta>); IMPL=wrong 4/6 fail, all exploit (ALLOW-FROM is ignored).
- Dependency freshness
- django 6.1.2 (2026-10-06) is taken because it is a security release (the same release the other Django cards pin); pytest-django 4.14.0 (2026-08-10) and pytest 9.1.1 (2026-06-19) are the latest. Python 3.14.8 is the 30 September 2026 security release. ContentSecurityPolicyMiddleware, SECURE_CSP and django.utils.csp.CSP are new in Django 6.0 and present in 6.1.2 (verified by running the middleware).
- Scanners
- Semgrep 1.180.0 in the pinned semgrep/semgrep image and Bandit 1.9.4 from a scratch venv, run (see scannerRules); no CodeQL query covers this weakness.
Versions and results hold for the toolchain above. The card's tests are the check: run them with your own versions before relying on the fix.
References
- Django 6.1: Clickjacking Protection (X-Frame-Options)
- Django 6.1: Content Security Policy (SECURE_CSP, CSP enum, csp_override)
- Django 6.1 settings: X_FRAME_OPTIONS, SECURE_CSP
- OWASP Clickjacking Defense Cheat Sheet
- CWE-1021: Improper Restriction of Rendered UI Layers or Frames
- HTML Standard: The X-Frame-Options header (processing model)
- CSP Level 3: frame-ancestors directive
Attack patterns: