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