Send a framing policy on sensitive pages. Content-Security-Policy: frame-ancestors 'none' (or 'self' and named partners) is what modern browsers honor; add X-Frame-Options: DENY for old ones. Do not rely on X-Frame-Options: ALLOW-FROM, which browsers ignore, or on frame-busting script, which a sandboxed iframe defeats.
- Decide which pages must never be framed (anything state-changing or showing sensitive data) and which, if any, you frame yourself.
- Send Content-Security-Policy: frame-ancestors 'none' on the pages that must not be framed, or frame-ancestors 'self' https://partner.example for pages you or a named partner frames.
- Add X-Frame-Options: DENY (or SAMEORIGIN) as a fallback for browsers too old for frame-ancestors. The two agree; CSP wins where both are understood.
- Apply the headers everywhere the page can be served: set them in middleware (for example helmet's frameguard plus a CSP), not per-handler, so a new route is covered by default.
- Delete frame-busting JavaScript and ALLOW-FROM headers; neither protects a modern browser.
- Add regression tests that read the response headers and assert a restrictive frame-ancestors is present.
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
export function framingHeaders() {
return {};
}With IMPL=vulnerable, 2 of 3 tests fail (2 exploit tests).
The fix
export function framingHeaders() {
return {
'Content-Security-Policy': "frame-ancestors 'none'",
'X-Frame-Options': 'DENY',
};
}With IMPL=fixed, 3 of 3 tests pass.
A fix that looks safe but is not
export function framingHeaders() {
return { 'X-Frame-Options': 'ALLOW-FROM https://partner.example.com' };
}With IMPL=wrong, 2 of 3 tests fail (2 exploit tests).
Route: the policy on the sensitive page
// Every response for the sensitive page carries impl.framingHeaders(), the
// framing policy a browser uses to decide whether another site may put this page
// in a frame, iframe, object or embed.
app.get('/settings/confirm', (_req, res) => {
for (const [name, value] of Object.entries(impl.framingHeaders())) res.set(name, value);
res.type('html').send(PAGE);
});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.
describe(`exploit (IMPL=${implName})`, () => {
test('a modern browser is told to refuse cross-origin framing', async () => {
const { headers } = await getConfirm();
assert.ok(
refusesCrossOriginFraming(headers),
`no effective framing protection. CSP=${headers.get('content-security-policy')} XFO=${headers.get('x-frame-options')}`,
);
});
test('the protection uses CSP frame-ancestors, not an obsolete header', async () => {
const { headers } = await getConfirm();
const ancestors = cspFrameAncestors(headers);
assert.ok(
ancestors !== undefined && ancestors !== '*' && ancestors !== '',
`frame-ancestors is missing or permissive (X-Frame-Options alone, and ALLOW-FROM in particular, is not honored by modern browsers): CSP=${headers.get('content-security-policy')}`,
);
});
});- X-Frame-Options: ALLOW-FROM is obsolete: Chromium never supported it and Firefox removed it, so a page that relies on it is framed anywhere. That is this card's wrong implementation. Use CSP frame-ancestors to allow specific origins.
- frame-ancestors is not covered by default-src: a CSP without an explicit frame-ancestors directive places no framing restriction at all.
- Two X-Frame-Options values, or X-Frame-Options plus a conflicting frame-ancestors, can be treated as invalid; send one coherent policy.
- Frame-busting scripts (if (top !== self) top.location = self.location) are defeated by the iframe sandbox attribute and by navigation-cancel tricks; they are not a control.
- Setting the header only on the HTML page and not on embeddable sub-resources, or forgetting it on error pages, leaves gaps.
- frame-ancestors governs who may frame you; it does not stop this page from framing others. That is a separate concern.
- Run the tests in all three modes: IMPL=fixed must pass; IMPL=vulnerable and IMPL=wrong must fail (node ../../run-all.mjs clickjacking/node-express does this for you).
- Fetch the page and read the headers: a restrictive Content-Security-Policy frame-ancestors (not * and not an http/https wildcard) must be present; X-Frame-Options: DENY or SAMEORIGIN is a bonus, ALLOW-FROM does not count.
- In a browser, build a test page on another origin with an <iframe> pointing at the sensitive URL. With the fix the frame is blank and the console shows the frame-ancestors refusal; the vulnerable and wrong builds render inside the frame. The tests assert on headers, which is what the browser acts on.
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/owasp-top-ten | 0 findings on app.js and impl/*.js. No registry rule flags a route that omits a framing policy, nor one that sets the obsolete X-Frame-Options: ALLOW-FROM.Checked: Semgrep 1.180.0 (CE) in semgrep/semgrep:1.180.0 |
Static analysis here cannot know which responses need a framing policy; it sees three handlers that set different (or no) headers. The regression tests, which read the response headers, are the control. The frameguard middleware in helmet sets X-Frame-Options and CSP frame-ancestors for a whole app; this card sets them directly to show what each does.
| Component | Version |
|---|---|
| Language | JavaScript (ES modules) |
| Runtime | Node.js 26.11.1 |
| Framework | Express 5.3.0 |
| express | 5.3.0 |
Download the project, install its dependencies once (this step needs the network), then run the tests against each implementation from the clickjacking/node-expressfolder.
npm ci# Expected: every test passes
IMPL=fixed npm test --silent
# Expected: an exploit test fails
IMPL=vulnerable npm test --silent
# Expected: a test fails
IMPL=wrong npm test --silentEach run writes a JUnit report to reports/junit.xml. From the top folder of the download, node run-all.mjs clickjacking/node-express runs all three modes and checks the expected results.
Verified
- Machine
- macOS 27.0 (Darwin 27.0.0, arm64)
- Toolchain
- node 26.11.1
- npm 11.20.0
- Matrix
- fixed: 3 of 3 tests pass; vulnerable: 2 of 3 tests fail (2 exploit tests); wrong: 2 of 3 tests fail (2 exploit tests)
- Test results
- IMPL=fixed 3/3 pass; IMPL=vulnerable 2/3 fail (both exploit: no framing headers at all); IMPL=wrong 2/3 fail (both exploit: only X-Frame-Options: ALLOW-FROM, which modern browsers ignore, and no CSP frame-ancestors).
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 Top 10:2025 A06 Insecure Design
- CWE-1021: Improper Restriction of Rendered UI Layers or Frames
- OWASP Clickjacking Defense Cheat Sheet
- MDN: CSP frame-ancestors
- MDN: X-Frame-Options (ALLOW-FROM is non-standard and obsolete)
- helmet: frameguard (X-Frame-Options)
Attack patterns: