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 -}}