Skip to content

fix(iframe-embed): server-side framable check so the XFO-DENY fallback actually triggers#16

Merged
rubenvdlinde merged 1 commit into
developmentfrom
fix/iframe-framable-server-check
Jul 26, 2026
Merged

fix(iframe-embed): server-side framable check so the XFO-DENY fallback actually triggers#16
rubenvdlinde merged 1 commit into
developmentfrom
fix/iframe-framable-server-check

Conversation

@rubenvdlinde

Copy link
Copy Markdown
Contributor

Found by live Playwright e2e: the iframe widget's graceful-degradation fallback (REQ-IFRAME-003) never fired for the primary case — a target sending X-Frame-Options: DENY or frame-ancestors 'none' (verified against real github.com). The user saw a permanently blank frame.

Root cause

The browser cannot distinguish an XFO/frame-ancestors block from a normal cross-origin embed — both leave the iframe's contentDocument null. The client-side onLoad heuristic therefore marked a blocked frame ready.

Fix — check server-side before rendering

  • IframeService::checkFramable() fetches the target and inspects X-Frame-Options (deny/sameorigin/allow-from) and CSP frame-ancestors; fail-closed on the allow-list and on unreachable/unverifiable targets.
  • IframeController::checkFramable()POST /api/iframe/framable (routed).
  • IframeWidget calls it on mount/url-change; a non-framable target renders the fallback card up front and the iframe isn't rendered at all.

Verification

  • PHPUnit: 26 green (+6: XFO deny/sameorigin, frame-ancestors none, host-not-allowed→no fetch, unreachable→fail-closed, framable-ok).
  • vitest: 13 green (+1: a server-reported refusal shows the fallback + renders no iframe).
  • Hydra gates: no new failures (6 reds are the pre-existing baseline; my @spec tags point at the canonical spec path).

Will live-verify against real github.com through Playwright after merge+redeploy.

…k actually triggers

Live e2e surfaced that the graceful-degradation fallback (REQ-IFRAME-003)
never fired for the primary case — a target sending X-Frame-Options: DENY or
frame-ancestors 'none'. The browser cannot tell such a block apart from a
normal cross-origin embed: both leave the iframe's contentDocument null, so
the client-side onLoad heuristic marked the blocked frame 'ready' and the user
saw a permanently blank frame instead of the fallback card.

Fix: check framing server-side before rendering the iframe.
- IframeService::checkFramable() fetches the target and inspects X-Frame-Options
  (deny/sameorigin/allow-from) and CSP frame-ancestors; fail-closed on the
  allow-list and on unreachable/unverifiable targets.
- IframeController::checkFramable() → POST /api/iframe/framable (registered).
- IframeWidget calls it on mount/url-change; a non-framable target renders the
  fallback card up front and the iframe is not rendered at all (no wasted load
  of a blocked target).

Tests: +6 PHPUnit (XFO deny/sameorigin, frame-ancestors none, host-not-allowed
no-fetch, unreachable fail-closed, framable-ok) — 26 green; +1 vitest asserting
a server-reported refusal shows the fallback and renders no iframe — 13 green.
Existing iframe tests updated to mock the new client call.
@rubenvdlinde
rubenvdlinde merged commit f6e4470 into development Jul 26, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant