From 6737a9af9eca791bdb1ffe7d5ff4d9dd1b7f21e7 Mon Sep 17 00:00:00 2001 From: Mark Dumay <61946753+markdumay@users.noreply.github.com> Date: Mon, 13 Jul 2026 12:23:45 +0200 Subject: [PATCH] fix(preview): honour frame-ancestors when detecting embedding restrictions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- layouts/partials/assets/preview.html | 202 +++++++++++++++++++++------ 1 file changed, 163 insertions(+), 39 deletions(-) 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 -}}