Send a framing policy on sensitive pages, in middleware so every matched response carries it. 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.
- Set security headers in middleware.ts (or next.config.ts headers()), so route handlers, server components and cached responses all carry them. Match the sensitive paths.
- Add Content-Security-Policy: frame-ancestors 'none' for 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; CSP wins where both are understood.
- Keep the matcher broad enough to cover every sensitive route and its sub-paths; a new page under the matched prefix is then protected by default.
- Remove any X-Frame-Options: ALLOW-FROM and frame-busting scripts; neither protects a modern browser.
- Add regression tests that run the middleware for a sensitive path 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(): Record<string, string> {
return {};
}With IMPL=vulnerable, 2 of 3 tests fail (2 exploit tests).
The fix
export function framingHeaders(): Record<string, string> {
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(): Record<string, string> {
return { 'X-Frame-Options': 'ALLOW-FROM https://partner.example.com' };
}With IMPL=wrong, 2 of 3 tests fail (2 exploit tests).
Middleware: the policy on the sensitive area
// Security headers belong in middleware (or next.config.ts headers()), so every
// matched response carries them, including ones a route handler or a cached page
// produces. Here middleware adds the framing policy to the sensitive area.
export function middleware(request: NextRequest) {
void request;
const response = NextResponse.next();
for (const [name, value] of Object.entries(currentImpl().framingHeaders())) {
response.headers.set(name, value);
}
return response;
}
export const config = { matcher: ['/settings/:path*'] };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', () => {
const headers = headersFor();
expect(
refusesCrossOriginFraming(headers),
`no effective framing protection. CSP=${headers.get('content-security-policy')} XFO=${headers.get('x-frame-options')}`,
).toBe(true);
});
test('the protection uses CSP frame-ancestors, not an obsolete header', () => {
const headers = headersFor();
const ancestors = cspFrameAncestors(headers);
expect(
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')}`,
).toBe(true);
});
});- X-Frame-Options: ALLOW-FROM is obsolete: Chromium never supported it and Firefox removed it, so a page relying on it is framed anywhere. That is this card's wrong implementation. Use CSP frame-ancestors to allow specific origins.
- A CSP without an explicit frame-ancestors directive places no framing restriction; default-src does not cover it.
- A middleware matcher that misses a route, or that excludes a route group, leaves that page unprotected. Test the paths that matter.
- Setting headers in a layout or page component instead of middleware or next.config headers(): a route handler for the same path can respond without them.
- Frame-busting JavaScript is defeated by the iframe sandbox attribute; it is not a control.
- frame-ancestors controls who may frame you, not whether you may frame others.
- Run the tests in all three modes: IMPL=fixed must pass; IMPL=vulnerable and IMPL=wrong must fail (node ../../run-all.mjs clickjacking/typescript-nextjs does this for you).
- Run the middleware (or fetch the page from a dev server) and read the headers: a restrictive Content-Security-Policy frame-ancestors must be present; X-Frame-Options: DENY or SAMEORIGIN is a bonus, ALLOW-FROM does not count.
- In a browser, point an <iframe> on another origin 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 in 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. No rule flags a response that omits a framing policy, nor middleware that sets the obsolete X-Frame-Options: ALLOW-FROM.Checked: Semgrep 1.180.0 (CE) in semgrep/semgrep:1.180.0 (middleware.ts, lib, app) |
Static analysis here sees three framingHeaders() values; it cannot know which routes need a framing policy. The regression tests, which run the middleware and read its headers, are the control.
| Component | Version |
|---|---|
| Language | TypeScript (Next.js App Router, middleware) |
| Runtime | Node.js 26.11.1 (middleware runs on the Node.js runtime) |
| Framework | Next.js 16.4.0 (middleware) |
| next | 16.4.0 |
| react, react-dom | 19.3.0 |
| vitest (test only) | 5.0.3 |
| typescript (type check and next build only) | 6.0.3 |
Download the project, install its dependencies once (this step needs the network), then run the tests against each implementation from the clickjacking/typescript-nextjsfolder.
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/typescript-nextjs 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
- next 16.4.0
- react 19.3.0
- vitest 5.0.3
- typescript 6.0.3
- 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); IMPL=wrong 2/3 fail (both exploit: only X-Frame-Options: ALLOW-FROM, which modern browsers ignore). tsc --noEmit passes.
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)
- Next.js: Middleware
- Next.js: Content Security Policy
Attack patterns: