Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
202 changes: 163 additions & 39 deletions layouts/partials/assets/preview.html
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,136 @@
</div>
{{- 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 */}}
Expand Down Expand Up @@ -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 -}}
Expand Down
Loading