From 61e756346c5fe8676b52087eab6c788ce0e505ab Mon Sep 17 00:00:00 2001 From: Mark Dumay <61946753+markdumay@users.noreply.github.com> Date: Mon, 13 Jul 2026 13:22:31 +0200 Subject: [PATCH] feat(preview): fall back when the site's own CSP blocks the iframe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The embedding detection added in v2.0.3 only inspects the target's response headers. That misses the other half of the contract: a target that happily allows framing still renders as an empty box when the embedding site's own CSP frame-src directive does not cover it, and no response header can rescue that. Every preview broken on gethinode.com and demo.gethinode.com failed this way, and the build reported nothing. Check the URL against the frame-src the site itself publishes, and show the fallback with an actionable warning when it is not covered. The published frame-src is the union of what each module contributes through [params.modules..csp], since the mod-csp baseline declares no frame-src of its own. A site that contributes nothing is not using this CSP system, so nothing can be concluded and the preview renders unchanged. The check runs after the response-header check and only for a target that would otherwise have loaded. Telling an author to add google.com to frame-src would send them after a fix that cannot work, because it refuses to be framed at all. Verified against a local server returning synthetic headers: the 15 existing header cases still produce the correct verdict over both the HEAD and the GET path, and 4 new cases cover the site side — a host allowed by frame-src, 'self', a host absent from frame-src, and a host that the target permits but the site does not. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.8 (1M context) --- layouts/partials/assets/preview.html | 60 ++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/layouts/partials/assets/preview.html b/layouts/partials/assets/preview.html index f4cef2b..8398da8 100644 --- a/layouts/partials/assets/preview.html +++ b/layouts/partials/assets/preview.html @@ -110,6 +110,45 @@ {{- return $match -}} {{- end -}} +{{/* Inline partial — reports whether this site's own Content-Security-Policy permits the target + URL to be framed. Even a perfectly embeddable target renders as an empty box when our own + frame-src leaves it out, and no response header can rescue that. + + The published frame-src is the union of what every module contributes through + [params.modules..csp], since the mod-csp baseline declares no frame-src of its own. + A site that contributes nothing is not using this CSP system, so nothing can be concluded + and the preview is rendered unchanged. */}} +{{- define "_partials/inline/preview-permitted-by-site.html" -}} + {{- $origin := .origin -}} + {{- $target := partial "inline/preview-origin.html" .url -}} + + {{- $sources := slice -}} + {{- range $module, $config := site.Params.modules -}} + {{- if reflect.IsMap $config -}} + {{- with index $config "csp" -}} + {{- if reflect.IsMap . -}} + {{- with index . "frame-src" -}} + {{- $sources = union $sources . -}} + {{- end -}} + {{- end -}} + {{- end -}} + {{- end -}} + {{- end -}} + + {{- $permitted := true -}} + {{- if $sources -}} + {{- $permitted = false -}} + {{- range $source := $sources -}} + {{/* The roles are mirrored here: a frame-src source constrains the URL being framed, + and 'self' means that URL shares this site's origin. */}} + {{- if partial "inline/preview-source-match.html" (dict "source" $source "origin" $target "target" $origin) -}} + {{- $permitted = true -}} + {{- end -}} + {{- end -}} + {{- end -}} + {{- return $permitted -}} +{{- end -}} + {{/* Inline partial — reports whether the target URL lets this site frame it, based on the X-Frame-Options and Content-Security-Policy headers of its response. */}} {{- define "_partials/inline/preview-embeddable.html" -}} @@ -208,6 +247,7 @@ {{/* Build-time detection of iframe embedding restrictions */}} {{- $showFallback := or $args.showFallback (not $args.url) -}} {{- $origin := partial "inline/preview-origin.html" site.BaseURL -}} + {{- if and $args.url (not $showFallback) $origin.host -}} {{- $options := dict "responseHeaders" (slice "X-Frame-Options" "Content-Security-Policy") -}} {{- $response := try (resources.GetRemote $args.url (merge $options (dict "method" "HEAD"))) -}} @@ -244,6 +284,26 @@ {{- end -}} {{- end -}} + {{/* A target that permits framing still renders as an empty box when this site's own CSP + leaves it out, so check that too. It runs last, and only for a target that would + otherwise have loaded — telling an author to allow a URL that refuses to be framed at + all would send them after a fix that cannot work. */}} + {{- if and $args.url (not $showFallback) $origin.host -}} + {{- if not (partial "inline/preview-permitted-by-site.html" (dict "url" $args.url "origin" $origin)) -}} + {{- $showFallback = true -}} + {{- partial "utilities/LogWarn.html" (dict + "partial" "assets/preview.html" + "warnid" "warn-preview-csp" + "msg" "iframe blocked by this site's own CSP" + "details" (slice + (printf "URL '%s' allows being framed, but is not covered by the frame-src directive this site publishes, so the browser refuses the iframe; showing fallback." $args.url) + "Add its host to a 'frame-src' entry under [modules..csp] to render it." + ) + "file" page.File + ) -}} + {{- end -}} + {{- end -}} + {{/* Optional section heading */}} {{ if $args.heading.title }} {{ partial "assets/section-title.html" (dict