Clickjacking attack: a local frame-ancestors test
Clickjacking is an attack where a page the attacker controls loads your page in a hidden frame, so a visitor's click lands on your button. We reproduced it by framing a local account page from a second origin and clicking a hidden delete button through a decoy. In vulnerable mode the page sent no framing headers, Chromium rendered the frame, and one click produced one delete request. With Content-Security-Policy: frame-ancestors 'none' and X-Frame-Options: DENY, Chromium blocked the frame and the same click produced zero.
This is a scripted Python, curl and headless Chromium experiment with downloadable source and responses. No AI agent or model ran the test.
Define the clickjacking attack and test scope
A clickjacking attack loads a target page inside a frame on a page the attacker controls. The frame is usually invisible and sits over a harmless-looking decoy. The visitor clicks the decoy and the click lands on a sensitive control in the target page, such as a delete or approve button. The browser sends the request with whatever the visitor is allowed to do on the target, so to the server it looks like a normal click.
Two response headers let a page refuse to be framed. The MDN reference for frame-ancestors says the directive specifies which parents may embed a page, and that 'none' is similar to X-Frame-Options: deny. The MDN reference for X-Frame-Options says DENY blocks framing from any origin, same-origin included, and recommends frame-ancestors for finer control. The OWASP Clickjacking Defense Cheat Sheet recommends using more than one of these mechanisms for defense in depth.
- Targets: a target server with an account page and a delete button, and a separate attacker server that frames it. Both bind IPv4 loopback,
127.0.0.1, on different free ports, so the browser sees two origins. - Requests: one
GET /accountwith curl per mode, plus two Chromium scenarios per mode: a click through the frame, and a click on the page opened directly. - Data: no accounts, cookies or external services. The target counts the
POST /deleterequests it receives, andGET /statereports that counter.
The fixture is intentionally vulnerable in its default mode and binds only to loopback. Test a real system only with the owner's written agreement on hosts, accounts and stopping conditions. The Excession Labs Acceptable Use Policy describes authorized use, and this article gives no permission to test another system.
Run the downloadable fixture
Download fixture.py, verify.py and browser_check.cjs into one directory. You need Python 3.9 or later and curl. The browser step also needs Node and the playwright package with a Chromium build. Point the runner at a node_modules directory that contains it:
PLAYWRIGHT_NODE_MODULES=/path/to/node_modules python3 verify.py --output rerun
The output directory must be new. Without Playwright the runner still checks headers and writes only the two curl cases. Our recorded run had the browser and ran all six cases. Each curl request bypasses proxies, times out after three seconds and does not follow redirects. Each scenario starts fresh servers, so the delete counter always begins at zero.
In the framed scenario, the attacker page at /attack holds a decoy button labeled "Claim your prize" and a fully transparent iframe over it. The iframe points at the target's /account page, where the real delete button sits at the same spot. The script clicks that spot once with page.mouse.click(200, 125) and then reads /state with curl.
The recorded run finished with {"checks": 6, "browser_run": true, "passed": true, "failures": []}. It ran on 10 November 2026 (UTC) with Python 3.15.2, curl 8.7.1, Node v22.23.4 and chromium 156.0.8078.4 on macOS ARM64. Ports, timestamps, source SHA-256 hashes and the browser's console output are in results.json. A rerun uses different ports.
Observed clickjacking results in both modes
Both modes served /account with status 200. Only the fixed mode sent framing headers. In the vulnerable run the target listened on port 53494 for the curl check and on 53497 for the framed click, with the attacker page on 53498. In the fixed run the target used 53518 for the framed click and the attacker page used 53520.
| Case | Expected secure result | Vulnerable mode | Fixed mode |
|---|---|---|---|
Content-Security-Policy on /account | frame-ancestors 'none' | absent | frame-ancestors 'none' |
X-Frame-Options on /account | DENY | absent | DENY |
| Frame from second origin | not rendered | rendered (delete button present) | blocked (frame URL chrome-error://chromewebdata/) |
| Delete requests after one click through the frame | 0 | 1 | 0 |
| Delete requests after one click on the page opened directly | 1 | 1 | 1 |
The direct click is the positive control. A server that blocked every request would also stop clickjacking, but it would break the page. After the fix the delete button still works when a user opens the page in a tab. The fixed-mode console message from Chromium reads: Framing 'http://127.0.0.1:53518/' violates the following Content Security Policy directive: "frame-ancestors 'none'". The request has been blocked. The raw files are fixed-framed-browser.json and fixed-framed-state.http.
Vulnerable mode produced no console message. The frame loaded, the click passed through the invisible iframe, and vulnerable-framed-state.http shows {"delete_requests": 1}.
Verify a clickjacking finding before reporting it
A missing header is a lead. It becomes a finding when you can show an attacker page causing a sensitive action. Check these points in order:
- The response for the sensitive page has no
X-Frame-Optionsand noframe-ancestorsdirective. Record the full headers, as in vulnerable-account-headers.http. - A page on a different origin can load it in a frame, and the frame renders the control. Record the browser, its version and the frame URL.
- One click, or a short fixed sequence of clicks, completes the action. A confirmation step, a re-entered password or a one-time token in the path changes the severity.
- The server state changed. Here that is the counter going from 0 to 1. Without this check, a rendered frame alone is weak evidence.
The impact of this fixture is one synthetic delete request. It says nothing about the severity of a missing header on a real page. Severity depends on what a single click can do for a signed-in user, and a static page with no sensitive controls usually has little to report. Our CSRF and SameSite experiment covers a related attack where another site makes the browser send a request for the user. The broken access control post covers the server-side checks on who may perform an action.
Send frame-ancestors and X-Frame-Options, then retest
The fixture adds both headers to every target response in fixed mode:
if self.server.role == "target" and self.server.mode == "fixed":
# The fix: forbid every origin from framing target responses.
self.send_header("Content-Security-Policy", "frame-ancestors 'none'")
self.send_header("X-Frame-Options", "DENY")
The X-Frame-Options line covers older browsers that do not support the CSP directive, according to MDN. OWASP advises setting the header on all responses that contain HTML. A real application would normally set both headers in its web server, proxy or framework middleware rather than in each handler. If a trusted partner must frame the page, replace 'none' with an explicit origin list in frame-ancestors. MDN marks the ALLOW-FROM value of X-Frame-Options as obsolete, so do not rely on it for allowlists.
The retest used new servers in fixed mode and the same click. The curl response carried both headers, Chromium refused to render the frame, and the counter stayed at 0. The page opened directly still accepted the click and the counter went to 1. Both headers were set together and we did not test each one alone. The console message names the CSP directive, which suggests Chromium enforced that one.
Inspect the evidence bundle
- Machine-readable results: all six cases, expected and observed values, ports, versions, console output and source hashes.
- Recorded commands: the curl and Node commands. The ports belong to the finished run.
- SHA-256 manifest: an integrity check for the files, not independent attestation.
- README, fixture source, verification source and browser script.
All data is synthetic. The bundle includes the raw HTTP responses and the browser outputs. No credentials or customer data were involved.
What this experiment does not cover
The fixture has no login and no cookies. A real session cookie raises the question of whether the browser sends it to a framed page, which depends on its SameSite attribute and the browser. Both servers share the host 127.0.0.1 and differ only by port, so this is a cross-origin test but not a cross-site one. We used one browser, Chromium, and a scripted mouse click instead of a person. Firefox and Safari were not tested.
We did not test multi-step clickjacking, drag-and-drop variants, double-click timing tricks, frame-busting scripts, partial framing with an allowlist, headers on error pages, or whether every HTML route in a real application carries the headers. The fixed result shows that two headers stop one framed page in one browser. It does not show that an unrelated application is protected.
In an engagement, list the pages with one-click sensitive actions first, check each response's headers, and prove impact in a browser with a state check as above. Retest after the change with the same pages and the same click. Excession Labs runs this kind of browser-level check in its security assessments. See how a finding like this looks in a sample report.