Skip to content

fix(preview): honour frame-ancestors when detecting embedding restrictions#151

Merged
markdumay merged 1 commit into
mainfrom
fix/preview-embed-detection
Jul 13, 2026
Merged

fix(preview): honour frame-ancestors when detecting embedding restrictions#151
markdumay merged 1 commit into
mainfrom
fix/preview-embed-detection

Conversation

@markdumay

Copy link
Copy Markdown
Contributor

Problem

The preview component renders a live iframe and, when it detects the target refuses to be framed,
falls back to a screenshot. Two defects meant the fallback almost never fired, so visitors got an
empty box where the preview should be.

1. frame-ancestors was only checked for 'none'.

{{- if findRE `frame-ancestors\s+'none'` . -}}

A target sending the far more common frame-ancestors 'self' was treated as embeddable. The
browser then refused the iframe at runtime — and nothing renders the fallback, because the client
script has no load-failure handling. This is what broke the live previews on
gethinode.com/showcases.

2. The check never ran at all on a default config.

It issues a HEAD request, but Hugo only whitelists GET and POST by default
(security.http.method) — the effective policy on a stock Hinode site is:

[security.http]
  methods = ['(?i)GET|POST']

so resources.GetRemote failed every time, and the error path silently assumed the target was
embeddable. Only gethinode.com, which explicitly whitelists HEAD, ever exercised the check.

Change

  • Evaluate frame-ancestors against this site's own origin, derived from site.BaseURL,
    instead of only looking for 'none'. Implements the host-source grammar: *, 'self',
    scheme-sources (https:), an optional scheme, wildcard subdomains (which correctly do not
    match the apex domain), and ports (defaulting to the scheme's default port).
  • Apply every policy in the response, so the most restrictive one wins.
  • Ignore X-Frame-Options when the CSP declares frame-ancestors, per spec, and honour
    SAMEORIGIN properly rather than treating any value as a block.
  • Retry with GET when HEAD is refused, so the check works on the default security config.
  • Warn — and still render the iframe — when the headers cannot be fetched at all, instead of
    failing silently.

The logic lives in three inline partials (preview-origin, preview-source-match,
preview-embeddable), following the file's existing preview-btn-group convention.

Verification

Built the exampleSite as if it were https://gethinode.com, against a local server returning
synthetic headers, plus four live sites. Before: 8 of 15 wrong — every case rendered a live
iframe, including frame-ancestors 'none' and X-Frame-Options: DENY
, confirming the check was
completely inert. After: 15/15 correct, over both the HEAD and the GET path.

Case Header Expected Result
allow-host frame-ancestors 'self' gethinode.com live
allow-wildcard frame-ancestors 'self' *.gethinode.com fallback (apex ≠ wildcard)
self-only frame-ancestors 'self' fallback
none frame-ancestors 'none' fallback
star frame-ancestors * live
scheme-source frame-ancestors https: live
port-mismatch frame-ancestors gethinode.com:8443 fallback
xfo-deny X-Frame-Options: DENY fallback
xfo-sameorigin X-Frame-Options: SAMEORIGIN fallback
csp-precedence XFO: DENY + frame-ancestors gethinode.com live (CSP wins)
no-headers live
real-adaptiq weareadaptiq.com live
real-demo demo.gethinode.com fallback
real-squadra squadra.nl fallback
real-self gethinode.com live

pnpm test (exampleSite build) passes.

Related

The showcase previews also need CSP config fixes, which are separate:
gethinode/gethinode.com#154, gethinode/hinode#2008, gethinode/theme-agency#384. This PR is what
guarantees a graceful screenshot rather than a blank box whenever a target does refuse framing.

🤖 Generated with Claude Code

…tions

The build-time check only flagged a target as unembeddable when it sent X-Frame-Options or a CSP
containing `frame-ancestors 'none'`. It never checked whether the frame-ancestors list actually
permits the embedding origin, so a target sending the far more common `frame-ancestors 'self'`
was treated as embeddable. The browser then refused the iframe at runtime and the visitor saw an
empty box, because the fallback that exists for exactly this case was never rendered.

The check was also inert in practice. It issues a HEAD request, but Hugo only whitelists GET and
POST by default (security.http.method), so resources.GetRemote failed on every Hinode site that
had not opted in, and the error path silently assumed the target was embeddable.

- Evaluate frame-ancestors against this site's own origin, derived from site.BaseURL. Implements
  the host-source grammar: '*', 'self', scheme-sources, an optional scheme, wildcard subdomains
  (which do not match the apex domain), and ports (defaulting to the scheme's default port).
- Apply every policy in the response, so the most restrictive one wins.
- Ignore X-Frame-Options when the CSP declares frame-ancestors, per spec, and honour SAMEORIGIN
  rather than treating any value as a block.
- Retry with GET when HEAD is refused, so the check works on the default security config.
- Warn, and still render the iframe, when the headers cannot be fetched at all.

Verified against a local server returning synthetic headers, covering frame-ancestors 'none',
'self', explicit hosts, wildcard hosts, scheme-sources, port mismatches, X-Frame-Options
DENY/SAMEORIGIN, CSP-over-XFO precedence and absent headers, plus four live sites. All 15 cases
produce the correct verdict over both the HEAD and the GET path.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@markdumay markdumay merged commit f739af3 into main Jul 13, 2026
8 checks passed
@markdumay

Copy link
Copy Markdown
Contributor Author

🎉 This PR is included in version 2.0.3 🎉

The release is available on GitHub release

Your semantic-release bot 📦🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant