From caa215037403f6290d9b28f5ec81fe520126bd82 Mon Sep 17 00:00:00 2001 From: Adur Date: Thu, 11 Jun 2026 19:21:03 +0200 Subject: [PATCH] fix: resolve 12 security findings (SSRF, XSS, CSP, XML injection) Client-side SSRF prevention: - IvooxAdapter: validate URL scheme (HTTPS only) and domain (ivoox.com only) before fetching third-party HTML - AzuraCastAdapter: validate API URL scheme (HTTPS only) before fetching - Tighten detectSourceType regexes to match actual domain patterns XSS hardening: - Add URL scheme validation to button, figure, hero, schedule shortcodes (reject javascript:, data:, vbscript: in href attributes) - Add URL scheme validation to podcast-player (audio src) and video shortcodes - Validate program-card icon contains actual SVG markup - Document unsafe=true security implications in exampleSite/hugo.toml Template injection hardening: - Replace endsWith URL matching with proper URL pathname+search comparison (avoids cross-source event handling from coincidental URL suffix matches) - Add sessionStorage state validation (reject non-HTTP schemes on restore) Content Security Policy: - Add CSP meta tag with nonce-based script-src - Apply nonce to all inline scripts (theme toggle, i18n injection, lang redirect, dropdowns) Subresource Integrity: - Add fingerprint + integrity to code-copy.js, podcast-player.js/css RSS/XML injection: - Replace Go-style %q quoting with XMLEscape in atom:link elements - Add XMLEscape to all RSS attribute values (itunes:image, itunes:category, itunes:author, itunes:name, itunes:email, itunes:subtitle, enclosure) Misc: - Use localStorage for language redirect flag (instead of sessionStorage) to prevent repeated redirects in incognito sessions --- assets/js/podcast-player.js | 61 ++++++++++++++++--- exampleSite/hugo.toml | 3 + exampleSite/layouts/_default/baseof.html | 18 +++--- exampleSite/layouts/_default/schedule.html | 8 +++ exampleSite/layouts/episodes/single.html | 8 +-- exampleSite/layouts/index.html | 8 +-- exampleSite/layouts/partials/hero.html | 5 ++ .../layouts/partials/program-card.html | 3 + layouts/_default/rss.xml | 16 ++--- layouts/_shortcodes/button.html | 4 ++ layouts/_shortcodes/figure.html | 4 ++ layouts/_shortcodes/podcast-player.html | 15 +++-- layouts/_shortcodes/video.html | 7 +++ 13 files changed, 124 insertions(+), 36 deletions(-) diff --git a/assets/js/podcast-player.js b/assets/js/podcast-player.js index a17efd0..926ff87 100644 --- a/assets/js/podcast-player.js +++ b/assets/js/podcast-player.js @@ -33,8 +33,8 @@ */ export function detectSourceType(url) { if (!url) return "local"; - if (/ivoox\.com/i.test(url)) return "ivoox"; - if (/azuracast/i.test(url) || /\.stream\./i.test(url)) return "azuracast"; + if (/^(https?:)?\/\/[^\/]*ivoox\.com/i.test(url)) return "ivoox"; + if (/azuracast\./i.test(url) || /\.stream\./i.test(url)) return "azuracast"; return "local"; } @@ -71,6 +71,9 @@ export class AzuracastAdapter { if (src) return src; const apiUrl = this.element.getAttribute("azuracast-api-url"); if (!apiUrl) throw new Error("AzuracastAdapter: missing azuracast-api-url attribute"); + // Validate scheme before fetching to prevent client-side SSRF + const parsed = new URL(apiUrl); + if (parsed.protocol !== "https:") throw new Error("AzuracastAdapter: azuracast-api-url must use HTTPS"); const resp = await fetch(apiUrl); if (!resp.ok) throw new Error(`AzuracastAdapter: API error ${resp.status}`); const data = await resp.json(); @@ -95,6 +98,14 @@ export class IvooxAdapter { async resolve() { const src = this.element.getAttribute("src"); if (!/ivoox\.com/i.test(src)) return src; + // Validate scheme and domain to prevent client-side SSRF + try { + const parsed = new URL(src); + if (parsed.protocol !== "https:" || !parsed.hostname.endsWith("ivoox.com")) { + console.warn("IvooxAdapter: refusing to fetch non-ivoox.com or non-HTTPS URL", src); + return src; + } + } catch { return src; } try { const resp = await fetch(src); if (!resp.ok) return src; @@ -113,6 +124,24 @@ export class IvooxAdapter { } /* ---- Open-source SVG icons (Feather/Lucide, MIT licensed) ---- */ + +/** + * Compare two URLs by pathname + search, ignoring protocol/domain differences. + * Falls back to suffix matching for non-URL strings. + * @param {string} a + * @param {string} b + * @returns {boolean} + */ +function urlsMatch(a, b) { + try { + const uA = new URL(a, document.baseURI); + const uB = new URL(b, document.baseURI); + if (uA.pathname === uB.pathname && uA.search === uB.search) return true; + // Also match if paths differ only by trailing slash + return uA.pathname.replace(/\/$/, "") === uB.pathname.replace(/\/$/, "") + && uA.search === uB.search; + } catch { return a.endsWith(b) || b.endsWith(a); } +} const ICON_SKIP_BACK = ''; const ICON_SKIP_FWD = @@ -1007,7 +1036,7 @@ class PodcastPlayer extends HTMLElement { const src = e.detail && e.detail.src; const mySrc = this._audio.src || this.getAttribute("src") || ""; // Only react if the event matches our source (or if unknown — backward compat) - if (src && src !== mySrc && !src.endsWith(mySrc) && !mySrc.endsWith(src)) return; + if (src && src !== mySrc && !urlsMatch(src, mySrc) && !urlsMatch(mySrc, src)) return; if (this._audio.src && !this._audio.paused) { this._audio.pause(); @@ -1042,7 +1071,7 @@ class PodcastPlayer extends HTMLElement { const mySrc = this._audio.src || this.getAttribute("src") || ""; if (!mySrc || !src) return; - if (mySrc === src || src.endsWith(mySrc) || mySrc.endsWith(src)) { + if (mySrc === src || urlsMatch(src, mySrc) || urlsMatch(mySrc, src)) { // Same source: someone else started our track — sync button to show pause this._els.playBtn.innerHTML = ''; this._els.playBtn.setAttribute("aria-label", this._t("player_pause")); @@ -1062,7 +1091,7 @@ class PodcastPlayer extends HTMLElement { // Only seek if the event's source matches ours — prevents a paused // player's progress bar interaction from affecting the active player. const mySrc = this._audio.src || this.getAttribute("src") || ""; - if (src && mySrc && src !== mySrc && !src.endsWith(mySrc) && !mySrc.endsWith(src)) return; + if (src && mySrc && src !== mySrc && !urlsMatch(src, mySrc) && !urlsMatch(mySrc, src)) return; if (!this._audio.duration) return; // Silently seek without dispatching our own seek event this._audio.currentTime = Math.min(time, this._audio.duration); @@ -1218,6 +1247,11 @@ class PodcastPlayer extends HTMLElement { if (currentSrc && state.src && !this._urlsMatch(state.src, currentSrc)) { return; } + // Validate restored src scheme to prevent sessionStorage manipulation + try { + const u = new URL(state.src, document.baseURI); + if (u.protocol !== "http:" && u.protocol !== "https:") return; + } catch (_) { return; } // Defer position + autoplay to loadedmetadata (where duration is known) this._pendingRestoreState = state; @@ -1363,7 +1397,7 @@ class PodcastPlayer extends HTMLElement { const d = e.detail || {}; // Only react if src matches const mySrc = this._audio.src || this.getAttribute("src") || ""; - if (d.src && mySrc && d.src !== mySrc && !d.src.endsWith(mySrc) && !mySrc.endsWith(d.src)) return; + if (d.src && mySrc && d.src !== mySrc && !urlsMatch(d.src, mySrc) && !urlsMatch(mySrc, d.src)) return; // Guard against echo — suppress our own dispatch while updating UI this._suppressSync = true; @@ -1786,7 +1820,7 @@ class PodcastFooter extends HTMLElement { const mySrc = this._audio.src || ""; // Ignore pauses from a different source (avoids incorrectly stopping when // one player replaces another) - if (src && mySrc && src !== mySrc && !src.endsWith(mySrc) && !mySrc.endsWith(src)) return; + if (src && mySrc && src !== mySrc && !urlsMatch(src, mySrc) && !urlsMatch(mySrc, src)) return; if (this._audio.src && !this._audio.paused) { this._audio.pause(); @@ -1830,7 +1864,7 @@ class PodcastFooter extends HTMLElement { if (time == null || !isFinite(time)) return; // Only seek if the source matches what the footer is playing const mySrc = this._audio.src || ""; - if (src && mySrc && src !== mySrc && !src.endsWith(mySrc) && !mySrc.endsWith(src)) return; + if (src && mySrc && src !== mySrc && !urlsMatch(src, mySrc) && !urlsMatch(mySrc, src)) return; if (!this._audio.duration) return; this._audio.currentTime = Math.min(time, this._audio.duration); } @@ -1919,7 +1953,7 @@ class PodcastFooter extends HTMLElement { const d = e.detail || {}; // Only react if src matches const mySrc = this._audio.src || ""; - if (d.src && mySrc && d.src !== mySrc && !d.src.endsWith(mySrc) && !mySrc.endsWith(d.src)) return; + if (d.src && mySrc && d.src !== mySrc && !urlsMatch(d.src, mySrc) && !urlsMatch(mySrc, d.src)) return; this._suppressSync = true; try { @@ -2028,6 +2062,15 @@ class PodcastFooter extends HTMLElement { if (!state.src) return; + // Validate restored src scheme to prevent sessionStorage manipulation + try { + const u = new URL(state.src, document.baseURI); + if (u.protocol !== "http:" && u.protocol !== "https:") { + console.warn("PodcastFooter: ignoring restored src with unsafe scheme", u.protocol); + return; + } + } catch (_) { return; } + // Restore volume/mute/rate immediately if (state.volume != null) this._audio.volume = state.volume; if (state.muted != null) this._audio.muted = state.muted; diff --git a/exampleSite/hugo.toml b/exampleSite/hugo.toml index 87099e9..acc810f 100644 --- a/exampleSite/hugo.toml +++ b/exampleSite/hugo.toml @@ -71,6 +71,9 @@ defaultContentLanguageInSubdir = false persistent = true preload = "metadata" +# WARNING: unsafe=true allows raw HTML in Markdown content (including - {{- $codeCopy := resources.Get "js/code-copy.js" -}} + {{- $codeCopy := resources.Get "js/code-copy.js" | fingerprint -}} {{- if $codeCopy }} - + {{- end }} {{- /* Always load podcast-player CSS so the persistent footer has theme support on every page */ -}} {{- $playerCSS := resources.Get "css/podcast-player.css" -}} @@ -101,7 +105,7 @@ - {{- /* Browser language auto-detection — redirect to preferred language on first visit */}} {{- if hugo.IsMultilingual }} - + {{- end }} - {{- $css := resources.Get "css/podcast-player.css" -}} + {{- $css := resources.Get "css/podcast-player.css" | fingerprint -}} {{- if $css }} - + {{- end }} {{- end }} diff --git a/exampleSite/layouts/index.html b/exampleSite/layouts/index.html index d18efba..29d2f83 100644 --- a/exampleSite/layouts/index.html +++ b/exampleSite/layouts/index.html @@ -3,13 +3,13 @@ {{- /* Load podcast-player assets manually since the home page uses no shortcode */ -}} {{- if not (.Page.Store.Get "podcastPlayerAssetsLoaded") -}} {{- .Page.Store.Set "podcastPlayerAssetsLoaded" true }} - {{- $js := resources.Get "js/podcast-player.js" -}} + {{- $js := resources.Get "js/podcast-player.js" | fingerprint -}} {{- if $js }} - + {{- end }} - {{- $css := resources.Get "css/podcast-player.css" -}} + {{- $css := resources.Get "css/podcast-player.css" | fingerprint -}} {{- if $css }} - + {{- end }} {{- end }} diff --git a/exampleSite/layouts/partials/hero.html b/exampleSite/layouts/partials/hero.html index 1a2a92c..6a3592b 100644 --- a/exampleSite/layouts/partials/hero.html +++ b/exampleSite/layouts/partials/hero.html @@ -10,6 +10,11 @@

{{ .title }}

{{- with .buttons }} diff --git a/exampleSite/layouts/partials/program-card.html b/exampleSite/layouts/partials/program-card.html index 5034279..d7baf79 100644 --- a/exampleSite/layouts/partials/program-card.html +++ b/exampleSite/layouts/partials/program-card.html @@ -1,5 +1,8 @@ {{- /* program-card.html - renders a single program card with icon, title, description, episodes */ -}} {{- $icon := .icon -}} +{{- if not (findRE "{{ . }}{{ else }}{{ .Site.Params.description | default .Site.Title }}{{ end }} {{ $podcast.language | default .Site.LanguageCode }} {{- with $podcast.copyright }}{{ . }}{{ end }} - {{- with $podcast.author }}{{ . }}{{ end }} + {{- with $podcast.author }}{{ . | transform.XMLEscape }}{{ end }} {{- with $podcast.summary }}{{ . | transform.XMLEscape | safeHTML }}{{ end }} - {{- with $podcast.subtitle }}{{ . }}{{ end }} - {{- with $podcast.image }}{{ end }} + {{- with $podcast.subtitle }}{{ . | transform.XMLEscape }}{{ end }} + {{- with $podcast.image }}{{ end }} {{- range $podcast.categories }} - - {{- with .subcategory }}{{ end }} + + {{- with .subcategory }}{{ end }} {{- end }} {{ $podcast.explicit | default "false" }} - {{- with $podcast.owner_name }}{{ . }}{{ with $podcast.owner_email }}{{ . }}{{ end }}{{ end }} + {{- with $podcast.owner_name }}{{ . | transform.XMLEscape }}{{ with $podcast.owner_email }}{{ . | transform.XMLEscape }}{{ end }}{{ end }} {{ $podcast.type | default "episodic" }} {{- with $podcast.block }}{{ . }}{{ end }} Hugo — wavecast theme {{ with .OutputFormats.Get "rss" }} - {{ printf "" .Permalink .MediaType | safeHTML }} + {{ printf `` (.Permalink | transform.XMLEscape) (.MediaType | transform.XMLEscape) | safeHTML }} {{ end }} {{- if not (eq (len $pages) 0) }} {{ (index ($pages | first 1) 0).Lastmod.Format "Mon, 02 Jan 2006 15:04:05 -0700" | safeHTML }} @@ -93,7 +93,7 @@ {{- $enclosureLen = len $res.Content }} {{- end }} {{- end }} - + {{- with $pPod.duration }}{{ . }}{{ end }} {{- with $pPod.author }}{{ . }}{{ else }}{{ with $podcast.author }}{{ . }}{{ end }}{{ end }} {{- with $pPod.subtitle }}{{ . | transform.XMLEscape | safeHTML }}{{ end }} diff --git a/layouts/_shortcodes/button.html b/layouts/_shortcodes/button.html index b81cea6..d4cecfe 100644 --- a/layouts/_shortcodes/button.html +++ b/layouts/_shortcodes/button.html @@ -3,6 +3,10 @@ variants: primary | secondary | outline */ -}} {{- $url := .Get "url" -}} +{{- /* Validate URL scheme — reject dangerous protocols */ -}} +{{- if and $url (not (or (hasPrefix $url "http://") (hasPrefix $url "https://") (hasPrefix $url "/") (hasPrefix $url "#") (hasPrefix $url "."))) -}} + {{- errorf "shortcode: URL must be HTTP(S), relative, fragment, or dot-relative path, got %q" $url -}} +{{- end -}} {{- $variant := .Get "variant" | default "primary" -}} {{- $icon := .Get "icon" -}} {{- $isExternal := or (hasPrefix $url "http://") (hasPrefix $url "https://") -}} diff --git a/layouts/_shortcodes/figure.html b/layouts/_shortcodes/figure.html index d848c9f..39c142c 100644 --- a/layouts/_shortcodes/figure.html +++ b/layouts/_shortcodes/figure.html @@ -8,6 +8,10 @@ {{- $width := .Get "width" -}} {{- $href := .Get "href" -}} {{- $class := .Get "class" | default "" -}} +{{- /* Validate URL scheme — reject dangerous protocols */ -}} +{{- if and $href (not (or (hasPrefix $href "http://") (hasPrefix $href "https://") (hasPrefix $href "/") (hasPrefix $href "#") (hasPrefix $href "."))) -}} + {{- errorf "shortcode: URL must be HTTP(S), relative, fragment, or dot-relative path, got %q" $href -}} +{{- end -}} {{- $isExternal := or (hasPrefix $href "http://") (hasPrefix $href "https://") (hasPrefix $href "//") -}}
diff --git a/layouts/_shortcodes/podcast-player.html b/layouts/_shortcodes/podcast-player.html index 6ef0729..24c08df 100644 --- a/layouts/_shortcodes/podcast-player.html +++ b/layouts/_shortcodes/podcast-player.html @@ -38,6 +38,13 @@ {{- if not $src -}} {{- errorf "podcast-player: required 'src' parameter is missing. See %s" .Position -}} {{- end -}} +{{- /* Validate audio URL scheme */ -}} +{{- if $src }} + {{- $scheme := urls.Parse $src }} + {{- if and $scheme.Scheme (not (or (eq $scheme.Scheme "http") (eq $scheme.Scheme "https"))) }} + {{- errorf "podcast-player: audio src must use http or https scheme, got %q" $scheme.Scheme }} + {{- end }} +{{- end -}} {{- /* ---- 2. Site config defaults ---- */ -}} {{- $siteCfg := site.Params.podcastPlayer | default dict -}} @@ -80,9 +87,9 @@ {{- .Page.Store.Set "podcastPlayerAssetsLoaded" true -}} {{- /* JS — link directly if asset exists, else inline fallback */ -}} - {{- $js := resources.Get "js/podcast-player.js" -}} + {{- $js := resources.Get "js/podcast-player.js" | fingerprint -}} {{- if $js -}} - + {{- else -}}