Software Securityhome
Fix a findingSearch
Fix card · A06:2025 Insecure Design

Fix clickjacking in Node.js and Express

The vulnerable code, the fix, a plausible fix that still fails, and the regression tests that tell them apart.

In short

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.

On this page

Steps

  1. Decide which pages must never be framed (anything state-changing or showing sensitive data) and which, if any, you frame yourself.
  2. 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.
  3. 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.
  4. 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.
  5. Delete frame-busting JavaScript and ALLOW-FROM headers; neither protects a modern browser.
  6. Add regression tests that read the response headers and assert a restrictive frame-ancestors is present.

Vulnerable, fixed and wrong

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

Vulnerableimpl/vulnerable.jsJavaScript
export function framingHeaders() {
  return {};
}

With IMPL=vulnerable, 2 of 3 tests fail (2 exploit tests).

The fix

Fixedimpl/fixed.jsJavaScript
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

Wrong fiximpl/wrong.jsJavaScript
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

app.jsJavaScript
// 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 regression test

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.

Testtest/framing.test.jsJavaScript
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')}`,
    );
  });
});

Pitfalls

  • 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.

Verify the fix

  1. 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).
  2. 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.
  3. 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.

Scanner rules

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.

Scanner rules for Fix clickjacking in Node.js and Express
ToolRuleWhat it found here
Semgrepp/owasp-top-ten0 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.

Libraries and versions

Libraries and versions
ComponentVersion
LanguageJavaScript (ES modules)
RuntimeNode.js 26.11.1
FrameworkExpress 5.3.0
express5.3.0

Run the tests

Download the project, install its dependencies once (this step needs the network), then run the tests against each implementation from the clickjacking/node-expressfolder.

Shell
npm ci
Shell
# 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 --silent

Each 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.

Download the project ZIP, 22 KB

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

  1. OWASP Top 10:2025 A06 Insecure Design
  2. CWE-1021: Improper Restriction of Rendered UI Layers or Frames
  3. OWASP Clickjacking Defense Cheat Sheet
  4. MDN: CSP frame-ancestors
  5. MDN: X-Frame-Options (ALLOW-FROM is non-standard and obsolete)
  6. helmet: frameguard (X-Frame-Options)

Attack patterns:

Find your next answer

Type to search. Search runs in your browser.

Searches run in your browser, but the host can infer them from the index files they load. The address shows your search so you can bookmark or share it.