diff --git a/layouts/partials/assets/preview.html b/layouts/partials/assets/preview.html
index dc218af..f4cef2b 100644
--- a/layouts/partials/assets/preview.html
+++ b/layouts/partials/assets/preview.html
@@ -34,6 +34,136 @@
{{- end -}}
+{{/* Inline partial — normalizes a URL into a comparable origin (scheme, host, port) */}}
+{{- define "_partials/inline/preview-origin.html" -}}
+ {{- $url := urls.Parse . -}}
+ {{- $scheme := lower $url.Scheme -}}
+ {{- $port := $url.Port -}}
+ {{- if not $port -}}
+ {{- if eq $scheme "https" -}}{{- $port = "443" -}}{{- else if eq $scheme "http" -}}{{- $port = "80" -}}{{- end -}}
+ {{- end -}}
+ {{- return (dict "scheme" $scheme "host" (lower $url.Hostname) "port" $port) -}}
+{{- end -}}
+
+{{/* Inline partial — reports whether a single CSP frame-ancestors source expression permits the
+ given origin. Covers the host-source grammar: an optional scheme, a host that may carry a
+ '*.' prefix, and an optional port that defaults to the scheme's default port. */}}
+{{- define "_partials/inline/preview-source-match.html" -}}
+ {{- $source := lower (trim .source " ") -}}
+ {{- $origin := .origin -}}
+ {{- $target := .target -}}
+ {{- $match := false -}}
+
+ {{- if eq $source "*" -}}
+ {{- $match = true -}}
+ {{- else if eq $source "'self'" -}}
+ {{- $match = and (eq $origin.scheme $target.scheme) (eq $origin.host $target.host) (eq $origin.port $target.port) -}}
+ {{- else if hasPrefix $source "'" -}}
+ {{/* Any other keyword source ('none', 'unsafe-inline', ...) never matches an origin */}}
+ {{- else -}}
+ {{- $scheme := "" -}}
+ {{- $rest := $source -}}
+ {{- if in $rest "://" -}}
+ {{- $parts := split $rest "://" -}}
+ {{- $scheme = index $parts 0 -}}
+ {{- $rest = index $parts 1 -}}
+ {{- else if hasSuffix $rest ":" -}}
+ {{/* Scheme-source, for example 'https:' — matches any host on that scheme */}}
+ {{- $scheme = trim $rest ":" -}}
+ {{- $rest = "" -}}
+ {{- end -}}
+
+ {{- if or (not $scheme) (eq $scheme $origin.scheme) -}}
+ {{- if not $rest -}}
+ {{- $match = true -}}
+ {{- else -}}
+ {{- $rest = index (split $rest "/") 0 -}}{{/* drop any path */}}
+ {{- $port := "" -}}
+ {{- if in $rest ":" -}}
+ {{- $hostPort := split $rest ":" -}}
+ {{- $rest = index $hostPort 0 -}}
+ {{- $port = index $hostPort 1 -}}
+ {{- end -}}
+
+ {{- $hostMatch := false -}}
+ {{- if hasPrefix $rest "*." -}}
+ {{/* A wildcard covers subdomains only, never the apex domain */}}
+ {{- $hostMatch = hasSuffix $origin.host (strings.TrimPrefix "*" $rest) -}}
+ {{- else -}}
+ {{- $hostMatch = eq $rest $origin.host -}}
+ {{- end -}}
+
+ {{- $portMatch := false -}}
+ {{- if eq $port "*" -}}
+ {{- $portMatch = true -}}
+ {{- else if $port -}}
+ {{- $portMatch = eq $port $origin.port -}}
+ {{- else -}}
+ {{/* Without an explicit port the scheme's default port is implied */}}
+ {{- $portMatch = eq $origin.port (cond (eq (or $scheme $origin.scheme) "http") "80" "443") -}}
+ {{- end -}}
+
+ {{- $match = and $hostMatch $portMatch -}}
+ {{- end -}}
+ {{- end -}}
+ {{- end -}}
+ {{- return $match -}}
+{{- 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" -}}
+ {{- $headers := .headers -}}
+ {{- $origin := .origin -}}
+ {{- $target := partial "inline/preview-origin.html" .url -}}
+ {{- $sameOrigin := and (eq $origin.scheme $target.scheme) (eq $origin.host $target.host) (eq $origin.port $target.port) -}}
+
+ {{- $blocked := false -}}
+ {{- $reason := "" -}}
+ {{- $declared := false -}}
+
+ {{/* Every policy in the response must permit the origin, so the most restrictive one wins */}}
+ {{- range $policy := index $headers "Content-Security-Policy" -}}
+ {{- range $directive := split $policy ";" -}}
+ {{- $tokens := slice -}}
+ {{- range $token := split $directive " " -}}
+ {{- with trim $token " \t\r\n" -}}
+ {{- $tokens = $tokens | append . -}}
+ {{- end -}}
+ {{- end -}}
+ {{- if and $tokens (eq (lower (index $tokens 0)) "frame-ancestors") -}}
+ {{- $declared = true -}}
+ {{- $sources := after 1 $tokens -}}
+ {{- $allowed := false -}}
+ {{- range $source := $sources -}}
+ {{- if partial "inline/preview-source-match.html" (dict "source" $source "origin" $origin "target" $target) -}}
+ {{- $allowed = true -}}
+ {{- end -}}
+ {{- end -}}
+ {{- if not $allowed -}}
+ {{- $blocked = true -}}
+ {{- $reason = printf "its CSP directive 'frame-ancestors %s' does not allow %s://%s" (delimit $sources " ") $origin.scheme $origin.host -}}
+ {{- end -}}
+ {{- end -}}
+ {{- end -}}
+ {{- end -}}
+
+ {{/* X-Frame-Options only applies when the CSP does not declare frame-ancestors */}}
+ {{- if not $declared -}}
+ {{- with index $headers "X-Frame-Options" -}}
+ {{- $value := upper (trim (index . 0) " ") -}}
+ {{- if and (eq $value "SAMEORIGIN") $sameOrigin -}}
+ {{/* Framed by its own origin, which SAMEORIGIN permits */}}
+ {{- else -}}
+ {{- $blocked = true -}}
+ {{- $reason = printf "it sets 'X-Frame-Options: %s' and %s://%s is a different origin" $value $origin.scheme $origin.host -}}
+ {{- end -}}
+ {{- end -}}
+ {{- end -}}
+
+ {{- return (dict "blocked" $blocked "reason" $reason) -}}
+{{- end -}}
+
{{ $error := false }}
{{/* Initialize arguments */}}
@@ -77,45 +207,39 @@
{{ if not $error }}
{{/* Build-time detection of iframe embedding restrictions */}}
{{- $showFallback := or $args.showFallback (not $args.url) -}}
- {{- if and $args.url (not $showFallback) -}}
- {{- with try (resources.GetRemote $args.url (dict
- "method" "HEAD"
- "responseHeaders" (slice "X-Frame-Options" "Content-Security-Policy")
- )) -}}
- {{- if not .Err -}}
- {{- $headers := .Value.Data.Headers -}}
- {{- with index $headers "X-Frame-Options" -}}
- {{- $showFallback = true -}}
- {{- partial "utilities/LogWarn.html" (dict
- "partial" "assets/preview.html"
- "warnid" "warn-preview-restricted"
- "msg" "iframe embedding blocked"
- "details" (slice (printf "URL '%s' sets X-Frame-Options; showing fallback" $args.url))
- "file" page.File
- ) -}}
- {{- end -}}
- {{- range index $headers "Content-Security-Policy" -}}
- {{- if findRE `frame-ancestors\s+'none'` . -}}
- {{- $showFallback = true -}}
- {{- partial "utilities/LogWarn.html" (dict
- "partial" "assets/preview.html"
- "warnid" "warn-preview-restricted"
- "msg" "iframe embedding blocked"
- "details" (slice (printf "URL '%s' sets CSP frame-ancestors 'none'; showing fallback" $args.url))
- "file" page.File
- ) -}}
- {{- end -}}
- {{- end -}}
- {{- else -}}
- {{- if $showFallback -}}
- {{- partial "utilities/LogWarn.html" (dict
- "partial" "assets/preview.html"
- "warnid" "warn-preview-restricted"
- "msg" "HEAD request failed"
- "details" (slice (printf "Could not check '%s' for embedding restrictions; fallback shown due to show_fallback setting" $args.url))
- "file" page.File
- ) -}}
- {{- end -}}
+ {{- $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"))) -}}
+ {{- if $response.Err -}}
+ {{/* Hugo only permits GET and POST by default (security.http.method), so fall back to
+ a GET request when HEAD is refused. */}}
+ {{- $response = try (resources.GetRemote $args.url $options) -}}
+ {{- end -}}
+
+ {{- if or $response.Err (not $response.Value) -}}
+ {{- partial "utilities/LogWarn.html" (dict
+ "partial" "assets/preview.html"
+ "warnid" "warn-preview-unverified"
+ "msg" "Could not verify embedding restrictions"
+ "details" (slice (printf "Request to '%s' failed: %v. Rendering the preview anyway." $args.url $response.Err))
+ "file" page.File
+ ) -}}
+ {{- else -}}
+ {{- $verdict := partial "inline/preview-embeddable.html" (dict
+ "url" $args.url
+ "headers" $response.Value.Data.Headers
+ "origin" $origin
+ ) -}}
+ {{- if $verdict.blocked -}}
+ {{- $showFallback = true -}}
+ {{- partial "utilities/LogWarn.html" (dict
+ "partial" "assets/preview.html"
+ "warnid" "warn-preview-restricted"
+ "msg" "iframe embedding blocked"
+ "details" (slice (printf "URL '%s' cannot be embedded because %s; showing fallback" $args.url $verdict.reason))
+ "file" page.File
+ ) -}}
{{- end -}}
{{- end -}}
{{- end -}}