Refuse framing in an HTTP response header, not in the page. Send Content-Security-Policy: frame-ancestors 'none' (which modern browsers honor over X-Frame-Options) plus X-Frame-Options: DENY for older browsers, on every page except one you mean to be embedded. A <meta> CSP and a JavaScript frame buster do not work.
- Decide which pages may be framed. Almost none should; a page with a signed-in action (the account-close confirmation here) is a clickjacking target and must refuse framing outright.
- Set the headers in one place with an after_request hook: Content-Security-Policy with a frame-ancestors directive, plus X-Frame-Options as the legacy fallback.
- Use frame-ancestors 'none' to refuse all framing, or 'self' to allow only same-origin; set X-Frame-Options to DENY or SAMEORIGIN to match. Never use ALLOW-FROM.
- Leave only the routes that are genuinely meant to be embedded (a public badge or widget) out of the restriction, by endpoint, so the default is safe.
- Delete any JavaScript frame buster and any <meta http-equiv> CSP you were relying on for framing, and confirm the real header is present.
- Add regression tests that read the response headers and assert framing is refused, exactly as a browser would judge them.
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
@bp.after_request
def set_framing_headers(response: Response) -> Response:
# Nothing is set: the app trusts the in-page frame buster and <meta> tag, neither
# of which a browser enforces.
return responseWith IMPL=vulnerable, 3 of 6 tests fail (3 exploit tests).
The fix: refuse framing in the response headers
@bp.after_request
def set_framing_headers(response: Response) -> Response:
# The public badge is deliberately embeddable, so it is the one route left exempt;
# everything else refuses all framing.
if request.endpoint == "pages.badge":
return response
response.headers["Content-Security-Policy"] = "frame-ancestors 'none'"
response.headers["X-Frame-Options"] = "DENY"
return responseWith IMPL=fixed, 6 of 6 tests pass.
A fix that looks safe but is not
@bp.after_request
def set_framing_headers(response: Response) -> Response:
if request.endpoint == "pages.badge":
return response
# ALLOW-FROM is a dead letter: modern browsers ignore it, so this is no protection.
response.headers["X-Frame-Options"] = "ALLOW-FROM https://partner.example"
return responseWith 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"- A JavaScript frame buster (if (top !== self) ...) is defeated by the iframe sandbox attribute, which disables the framed page's scripts while still showing it, so it is not a control.
- frame-ancestors in a <meta http-equiv="Content-Security-Policy"> tag is ignored by browsers: CSP Level 3 says frame-ancestors (like report-uri and sandbox) works only when delivered as an HTTP header.
- X-Frame-Options: ALLOW-FROM was never implemented by Chrome or Safari and the HTML Standard says it is not to be implemented; a browser treats it as an unrecognized value and allows framing from anywhere. Use frame-ancestors for an allowlist instead.
- When both are present, a browser that understands CSP ignores X-Frame-Options and obeys frame-ancestors (HTML Standard), so keep the two consistent; send X-Frame-Options only for older browsers.
- X-Frame-Options: SAMEORIGIN checks every ancestor frame up the chain, not just the top, so a same-origin page framed inside a cross-origin page is still refused.
- frame-ancestors does not fall back to default-src, so default-src 'none' alone does not stop framing; you must name frame-ancestors.
- Content-Security-Policy-Report-Only does not block framing; it only reports. Enforce the policy with the Content-Security-Policy header.
- Run the tests in all three modes: IMPL=fixed must pass; IMPL=vulnerable and IMPL=wrong must fail (node ../../run-all.mjs clickjacking/python-flask does this for you).
- Against your own endpoint, request a protected page and confirm the response carries Content-Security-Policy: frame-ancestors 'none' (or 'self') and X-Frame-Options: DENY (or SAMEORIGIN); confirm a page you mean to embed carries neither.
- Load the page in an <iframe> on another origin in a current browser: a protected page must be blank/blocked with a console message, and the embeddable badge must still show. No scanner checked here detects a missing framing 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/flask | 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/flask --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 | Flask 3.1.3 |
| flask | 3.1.3 |
| werkzeug (Response headers) | 3.1.9 |
| pytest | 9.1.1 |
Download the project, install its dependencies once (this step needs the network), then run the tests against each implementation from the clickjacking/python-flaskfolder.
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-flask 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
- flask 3.1.3
- werkzeug 3.1.9
- pytest 9.1.1
- 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 (the ALLOW-FROM value is ignored, so the same three plus the ALLOW-FROM check).
- Dependency freshness
- flask 3.1.3 (2026-02-19), werkzeug 3.1.9 (2026-09-27, a security release), pytest 9.1.1 (2026-06-19) are the latest releases. Python 3.14.8 is the 30 September 2026 expedited security release.
- Scanners
- Semgrep 1.180.0 in the pinned semgrep/semgrep image and Bandit 1.9.4 from a scratch venv, run (see scannerRules); CodeQL has no framing-header query.
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
- 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
- MDN: Content-Security-Policy frame-ancestors
- MDN: X-Frame-Options
- Flask: Response (after_request, headers)
Attack patterns: