Kaynağa Gözat

fix(template): replace safeURL with untrustedURL scheme validator

safeURL wrapped any string in template.URL, defeating html/template's
URL filter and allowing javascript:/data: URIs from feed entries to
render verbatim.

untrustedURL validates the scheme via sanitizer.HasValidURIScheme,
falling back to "#" otherwise. The sanitizer allowlist is reused
because html/template's built-in filter is too narrow for feeds (only
http(s), mailto, and relative URLs).
Frédéric Guillot 1 ay önce
ebeveyn
işleme
64baebad6f

+ 2 - 59
internal/reader/sanitizer/sanitizer.go

@@ -148,54 +148,6 @@ var (
 		"x.com/share",
 		"x.com/share",
 	}
 	}
 
 
-	// See https://www.iana.org/assignments/uri-schemes/uri-schemes.xhtml
-	validURISchemes = []string{
-		// Most commong schemes on top.
-		"https:",
-		"http:",
-
-		// Then the rest.
-		"apt:",
-		"bitcoin:",
-		"callto:",
-		"dav:",
-		"davs:",
-		"ed2k:",
-		"facetime:",
-		"feed:",
-		"ftp:",
-		"geo:",
-		"git:",
-		"gopher:",
-		"irc:",
-		"irc6:",
-		"ircs:",
-		"itms-apps:",
-		"itms:",
-		"magnet:",
-		"mailto:",
-		"news:",
-		"nntp:",
-		"rtmp:",
-		"sftp:",
-		"sip:",
-		"sips:",
-		"shortcuts:",
-		"skype:",
-		"spotify:",
-		"ssh:",
-		"steam:",
-		"svn:",
-		"svn+ssh:",
-		"tel:",
-		"webcal:",
-		"xmpp:",
-
-		// iOS Apps
-		"opener:", // https://www.opener.link
-		"hack:",   // https://apps.apple.com/it/app/hack-for-hacker-news-reader/id1464477788?l=en-GB
-	}
-
 	dataAttributeAllowedPrefixes = []string{
 	dataAttributeAllowedPrefixes = []string{
 		"data:image/avif",
 		"data:image/avif",
 		"data:image/apng",
 		"data:image/apng",
@@ -351,15 +303,6 @@ func hasRequiredAttributes(s *mandatoryAttributesStruct, tagName string) bool {
 	return true
 	return true
 }
 }
 
 
-func hasValidURIScheme(absoluteURL string) bool {
-	for _, scheme := range validURISchemes {
-		if strings.HasPrefix(absoluteURL, scheme) {
-			return true
-		}
-	}
-	return false
-}
-
 func isBlockedResource(absoluteURL string) bool {
 func isBlockedResource(absoluteURL string) bool {
 	for _, blockedURL := range blockedResourceURLSubstrings {
 	for _, blockedURL := range blockedResourceURLSubstrings {
 		if strings.Contains(absoluteURL, blockedURL) {
 		if strings.Contains(absoluteURL, blockedURL) {
@@ -588,7 +531,7 @@ func sanitizeAttributes(parsedBaseUrl *url.URL, tagName string, attributes []htm
 					continue
 					continue
 				}
 				}
 
 
-				if !hasValidURIScheme(value) {
+				if !HasValidURIScheme(value) {
 					continue
 					continue
 				}
 				}
 
 
@@ -652,7 +595,7 @@ func sanitizeSrcsetAttr(parsedBaseURL *url.URL, value string) string {
 			continue
 			continue
 		}
 		}
 
 
-		if !hasValidURIScheme(absoluteURL) || isBlockedResource(absoluteURL) {
+		if !HasValidURIScheme(absoluteURL) || isBlockedResource(absoluteURL) {
 			continue
 			continue
 		}
 		}
 
 

+ 68 - 0
internal/reader/sanitizer/url.go

@@ -0,0 +1,68 @@
+// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+package sanitizer // import "miniflux.app/v2/internal/reader/sanitizer"
+
+import "strings"
+
+// validURISchemes is the allowlist for URLs in sanitized feed body content.
+// It is intentionally broad; stricter surfaces (redirects, template hrefs)
+// should use urllib.IsAbsoluteURL / urllib.IsRelativePath instead.
+//
+// See https://www.iana.org/assignments/uri-schemes/uri-schemes.xhtml
+var validURISchemes = []string{
+	// Most commong schemes on top.
+	"https:",
+	"http:",
+
+	// Then the rest.
+	"apt:",
+	"bitcoin:",
+	"callto:",
+	"dav:",
+	"davs:",
+	"ed2k:",
+	"facetime:",
+	"feed:",
+	"ftp:",
+	"geo:",
+	"git:",
+	"gopher:",
+	"irc:",
+	"irc6:",
+	"ircs:",
+	"itms-apps:",
+	"itms:",
+	"magnet:",
+	"mailto:",
+	"news:",
+	"nntp:",
+	"rtmp:",
+	"sftp:",
+	"sip:",
+	"sips:",
+	"shortcuts:",
+	"skype:",
+	"spotify:",
+	"ssh:",
+	"steam:",
+	"svn:",
+	"svn+ssh:",
+	"tel:",
+	"webcal:",
+	"xmpp:",
+
+	// iOS Apps
+	"opener:", // https://www.opener.link
+	"hack:",   // https://apps.apple.com/it/app/hack-for-hacker-news-reader/id1464477788?l=en-GB
+}
+
+// HasValidURIScheme reports whether the URL begins with an allowed scheme.
+func HasValidURIScheme(absoluteURL string) bool {
+	for _, scheme := range validURISchemes {
+		if strings.HasPrefix(absoluteURL, scheme) {
+			return true
+		}
+	}
+	return false
+}

+ 47 - 0
internal/reader/sanitizer/url_test.go

@@ -0,0 +1,47 @@
+// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+package sanitizer // import "miniflux.app/v2/internal/reader/sanitizer"
+
+import "testing"
+
+func TestHasValidURIScheme(t *testing.T) {
+	scenarios := map[string]bool{
+		// Allowed: web schemes.
+		"http://example.org/article":  true,
+		"https://example.org/article": true,
+
+		// Allowed: a sample of the broader feed-content schemes.
+		"mailto:author@example.org": true,
+		"magnet:?xt=urn:btih:abc":   true,
+		"tel:+15551234567":          true,
+		"ftp://example.org/file":    true,
+		"feed:https://example.org/": true,
+		"webcal://example.org/cal":  true,
+
+		// Rejected: schemes that enable script execution or local resource access.
+		"javascript:alert(1)":                      false,
+		"data:text/html,<script>alert(1)</script>": false,
+		"vbscript:msgbox(1)":                       false,
+		"file:///etc/passwd":                       false,
+
+		// Rejected: missing or malformed scheme.
+		"":                        false,
+		"example.org":             false,
+		"/relative/path":          false,
+		"//evil.example.org/path": false,
+
+		// Rejected: case-sensitive match (callers are expected to pass
+		// already-normalized URLs, e.g. via net/url which lowercases the scheme).
+		"HTTPS://example.org": false,
+		"JavaScript:alert(1)": false,
+	}
+
+	for input, expected := range scenarios {
+		t.Run(input, func(t *testing.T) {
+			if actual := HasValidURIScheme(input); actual != expected {
+				t.Errorf("HasValidURIScheme(%q) = %v, want %v", input, actual, expected)
+			}
+		})
+	}
+}

+ 16 - 3
internal/template/functions.go

@@ -20,6 +20,7 @@ import (
 	"miniflux.app/v2/internal/locale"
 	"miniflux.app/v2/internal/locale"
 	"miniflux.app/v2/internal/mediaproxy"
 	"miniflux.app/v2/internal/mediaproxy"
 	"miniflux.app/v2/internal/model"
 	"miniflux.app/v2/internal/model"
+	"miniflux.app/v2/internal/reader/sanitizer"
 	"miniflux.app/v2/internal/timezone"
 	"miniflux.app/v2/internal/timezone"
 	"miniflux.app/v2/internal/ui/static"
 	"miniflux.app/v2/internal/ui/static"
 	"miniflux.app/v2/internal/urllib"
 	"miniflux.app/v2/internal/urllib"
@@ -56,9 +57,7 @@ func (f *funcMap) Map() template.FuncMap {
 			}
 			}
 			return f.basePath + format
 			return f.basePath + format
 		},
 		},
-		"safeURL": func(url string) template.URL {
-			return template.URL(url)
-		},
+		"untrustedURL": untrustedURL,
 		"safeCSS": func(str string) template.CSS {
 		"safeCSS": func(str string) template.CSS {
 			return template.CSS(str)
 			return template.CSS(str)
 		},
 		},
@@ -242,6 +241,20 @@ func isEmail(str string) bool {
 	return err == nil
 	return err == nil
 }
 }
 
 
+// untrustedURL validates a feed-supplied URL against the sanitizer's scheme
+// allowlist before exposing it to html/template. Returns "#" for unsafe URLs
+// (e.g. javascript:, data:) so anchors render as inert links.
+//
+// Go's built-in html/template URL filter only allows http(s), mailto, and
+// relative URLs — too narrow for feeds which legitimately use schemes like
+// magnet:, feed:, webcal:, and tel:.
+func untrustedURL(rawURL string) template.URL {
+	if !sanitizer.HasValidURIScheme(rawURL) {
+		return template.URL("#")
+	}
+	return template.URL(rawURL)
+}
+
 // Returns the duration in human readable format (hours and minutes).
 // Returns the duration in human readable format (hours and minutes).
 func duration(t time.Time) string {
 func duration(t time.Time) string {
 	return durationImpl(t, time.Now())
 	return durationImpl(t, time.Now())

+ 31 - 0
internal/template/functions_test.go

@@ -4,6 +4,7 @@
 package template // import "miniflux.app/v2/internal/template"
 package template // import "miniflux.app/v2/internal/template"
 
 
 import (
 import (
+	"html/template"
 	"strings"
 	"strings"
 	"testing"
 	"testing"
 	"time"
 	"time"
@@ -126,6 +127,36 @@ func TestIsEmail(t *testing.T) {
 	}
 	}
 }
 }
 
 
+func TestUntrustedURL(t *testing.T) {
+	scenarios := map[string]template.URL{
+		// Pass-through: schemes accepted by the sanitizer allowlist.
+		"https://example.org/article": "https://example.org/article",
+		"http://example.org/article":  "http://example.org/article",
+		"mailto:author@example.org":   "mailto:author@example.org",
+		"magnet:?xt=urn:btih:abc":     "magnet:?xt=urn:btih:abc",
+		"feed:https://example.org/":   "feed:https://example.org/",
+
+		// Rewritten to "#": schemes that enable script execution or
+		// local-resource access, plus malformed inputs.
+		"javascript:alert(1)":                      "#",
+		"JavaScript:alert(1)":                      "#",
+		"data:text/html,<script>alert(1)</script>": "#",
+		"vbscript:msgbox(1)":                       "#",
+		"file:///etc/passwd":                       "#",
+		"//evil.example.org/path":                  "#",
+		"/relative/path":                           "#",
+		"":                                         "#",
+	}
+
+	for input, expected := range scenarios {
+		t.Run(input, func(t *testing.T) {
+			if actual := untrustedURL(input); actual != expected {
+				t.Errorf("untrustedURL(%q) = %q, want %q", input, actual, expected)
+			}
+		})
+	}
+}
+
 func TestDuration(t *testing.T) {
 func TestDuration(t *testing.T) {
 	now := time.Now()
 	now := time.Now()
 	var dt = []struct {
 	var dt = []struct {

+ 1 - 1
internal/template/templates/common/feed_list.html

@@ -35,7 +35,7 @@
             <div class="item-meta">
             <div class="item-meta">
                 <ul class="item-meta-info">
                 <ul class="item-meta-info">
                     <li class="item-meta-info-site-url" dir="auto">
                     <li class="item-meta-info-site-url" dir="auto">
-                        <a href="{{ .SiteURL | safeURL }}" title="{{ .SiteURL }}" {{ if $.user.OpenExternalLinksInNewTab }}target="_blank"{{ else }}rel="noopener"{{ end }} data-original-link="{{ $.user.MarkReadOnView }}">
+                        <a href="{{ .SiteURL }}" title="{{ .SiteURL }}" {{ if $.user.OpenExternalLinksInNewTab }}target="_blank"{{ else }}rel="noopener"{{ end }} data-original-link="{{ $.user.MarkReadOnView }}">
                             {{ domain .SiteURL }}
                             {{ domain .SiteURL }}
                         </a>
                         </a>
                     </li>
                     </li>

+ 2 - 2
internal/template/templates/common/item_meta.html

@@ -67,14 +67,14 @@
             </li>
             </li>
         {{ end -}}
         {{ end -}}
         <li class="item-meta-icons-external-url">
         <li class="item-meta-icons-external-url">
-            <a href="{{ .entry.URL | safeURL  }}"
+            <a href="{{ .entry.URL | untrustedURL }}"
                 aria-describedby="entry-title-{{ .entry.ID }}"
                 aria-describedby="entry-title-{{ .entry.ID }}"
                 {{ if $.user.OpenExternalLinksInNewTab }}target="_blank"{{ else }}rel="noopener"{{ end }}
                 {{ if $.user.OpenExternalLinksInNewTab }}target="_blank"{{ else }}rel="noopener"{{ end }}
                 data-original-link="{{ .user.MarkReadOnView }}">{{ icon "external-link" }}<span class="icon-label">{{ t "entry.external_link.label" }}</span></a>
                 data-original-link="{{ .user.MarkReadOnView }}">{{ icon "external-link" }}<span class="icon-label">{{ t "entry.external_link.label" }}</span></a>
         </li>
         </li>
         {{ if .entry.CommentsURL }}
         {{ if .entry.CommentsURL }}
             <li class="item-meta-icons-comments">
             <li class="item-meta-icons-comments">
-                <a href="{{ .entry.CommentsURL | safeURL  }}"
+                <a href="{{ .entry.CommentsURL }}"
                     aria-describedby="entry-title-{{ .entry.ID }}"
                     aria-describedby="entry-title-{{ .entry.ID }}"
                     title="{{ t "entry.comments.title" }}"
                     title="{{ t "entry.comments.title" }}"
                     {{ if $.user.OpenExternalLinksInNewTab }}target="_blank"{{ else }}rel="noopener"{{ end }}
                     {{ if $.user.OpenExternalLinksInNewTab }}target="_blank"{{ else }}rel="noopener"{{ end }}

+ 2 - 2
internal/template/templates/views/choose_subscription.html

@@ -43,8 +43,8 @@
 
 
     {{ range .subscriptions }}
     {{ range .subscriptions }}
         <div class="radio-group">
         <div class="radio-group">
-            <label title="{{ .URL | safeURL  }}"><input type="radio" name="url" value="{{ .URL | safeURL  }}"> {{ .Title }}</label> ({{ .Type }})
-            <small title="Type = {{ .Type }}"><a href="{{ .URL | safeURL  }}" {{ if $.user.OpenExternalLinksInNewTab }}target="_blank"{{ else }}rel="noopener"{{ end }}>{{ .URL | safeURL  }}</a></small>
+            <label title="{{ .URL }}"><input type="radio" name="url" value="{{ .URL }}"> {{ .Title }}</label> ({{ .Type }})
+            <small title="Type = {{ .Type }}"><a href="{{ .URL }}" {{ if $.user.OpenExternalLinksInNewTab }}target="_blank"{{ else }}rel="noopener"{{ end }}>{{ .URL }}</a></small>
         </div>
         </div>
     {{ end }}
     {{ end }}
 
 

+ 8 - 8
internal/template/templates/views/entry.html

@@ -45,7 +45,7 @@
 <section class="entry" data-id="{{ .entry.ID }}" aria-labelledby="page-header-title">
 <section class="entry" data-id="{{ .entry.ID }}" aria-labelledby="page-header-title">
     <header class="entry-header">
     <header class="entry-header">
         <h1 id="page-header-title" dir="auto">
         <h1 id="page-header-title" dir="auto">
-            <a href="{{ .entry.URL | safeURL }}" {{ if $.user.OpenExternalLinksInNewTab }}target="_blank"{{ else }}rel="noopener"{{ end }}>{{ .entry.Title }}</a>
+            <a href="{{ .entry.URL | untrustedURL }}" {{ if $.user.OpenExternalLinksInNewTab }}target="_blank"{{ else }}rel="noopener"{{ end }}>{{ .entry.Title }}</a>
         </h1>
         </h1>
         {{ if .user }}
         {{ if .user }}
         <div class="entry-actions">
         <div class="entry-actions">
@@ -127,7 +127,7 @@
                 </li>
                 </li>
                 {{ if .entry.CommentsURL }}
                 {{ if .entry.CommentsURL }}
                 <li>
                 <li>
-                    <a href="{{ .entry.CommentsURL | safeURL }}"
+                    <a href="{{ .entry.CommentsURL }}"
                         class="page-link"
                         class="page-link"
                         title="{{ t "entry.comments.title" }}"
                         title="{{ t "entry.comments.title" }}"
                         {{ if $.user.OpenExternalLinksInNewTab }}target="_blank"{{ else }}rel="noopener"{{ end }}
                         {{ if $.user.OpenExternalLinksInNewTab }}target="_blank"{{ else }}rel="noopener"{{ end }}
@@ -146,7 +146,7 @@
                 {{ if .user }}
                 {{ if .user }}
                 <a href="{{ routePath "/feed/%d/entries" .entry.Feed.ID }}">{{ .entry.Feed.Title }}</a>
                 <a href="{{ routePath "/feed/%d/entries" .entry.Feed.ID }}">{{ .entry.Feed.Title }}</a>
                 {{ else }}
                 {{ else }}
-                <a href="{{ .entry.Feed.SiteURL | safeURL }}">{{ .entry.Feed.Title }}</a>
+                <a href="{{ .entry.Feed.SiteURL }}">{{ .entry.Feed.Title }}</a>
                 {{ end }}
                 {{ end }}
             </span>
             </span>
             {{ if .entry.Author }}
             {{ if .entry.Author }}
@@ -206,7 +206,7 @@
         {{ end }}
         {{ end }}
         <div class="entry-external-link">
         <div class="entry-external-link">
             <a
             <a
-                href="{{ .entry.URL | safeURL  }}"
+                href="{{ .entry.URL | untrustedURL }}"
                 {{ if $.user.OpenExternalLinksInNewTab }}target="_blank"{{ else }}rel="noopener"{{ end }}
                 {{ if $.user.OpenExternalLinksInNewTab }}target="_blank"{{ else }}rel="noopener"{{ end }}
                 data-original-link="{{ $.user.MarkReadOnView }}">{{ .entry.URL }}</span></a>
                 data-original-link="{{ $.user.MarkReadOnView }}">{{ .entry.URL }}</span></a>
         </div>
         </div>
@@ -254,7 +254,7 @@
                             {{ if (and $.user (mustBeProxyfied "audio")) }}
                             {{ if (and $.user (mustBeProxyfied "audio")) }}
                             <source src="{{ proxyURL .URL }}" type="{{ .Html5MimeType }}">
                             <source src="{{ proxyURL .URL }}" type="{{ .Html5MimeType }}">
                             {{ else }}
                             {{ else }}
-                            <source src="{{ .URL | safeURL }}" type="{{ .Html5MimeType }}">
+                            <source src="{{ .URL | untrustedURL }}" type="{{ .Html5MimeType }}">
                             {{ end }}
                             {{ end }}
                         </audio>
                         </audio>
                         {{ template "enclosure_media_controls" . }}
                         {{ template "enclosure_media_controls" . }}
@@ -271,7 +271,7 @@
                             {{ if (and $.user (mustBeProxyfied "video")) }}
                             {{ if (and $.user (mustBeProxyfied "video")) }}
                             <source src="{{ proxyURL .URL }}" type="{{ .Html5MimeType }}">
                             <source src="{{ proxyURL .URL }}" type="{{ .Html5MimeType }}">
                             {{ else }}
                             {{ else }}
-                            <source src="{{ .URL | safeURL }}" type="{{ .Html5MimeType }}">
+                            <source src="{{ .URL | untrustedURL }}" type="{{ .Html5MimeType }}">
                             {{ end }}
                             {{ end }}
                         </video>
                         </video>
                         {{ template "enclosure_media_controls" . }}
                         {{ template "enclosure_media_controls" . }}
@@ -298,13 +298,13 @@
             {{ if (and $.user (mustBeProxyfied "image")) }}
             {{ if (and $.user (mustBeProxyfied "image")) }}
             <img src="{{ proxyURL .URL }}" title="{{ .URL }} ({{ .MimeType }})" loading="lazy" alt="{{ .URL }} ({{ .MimeType }})">
             <img src="{{ proxyURL .URL }}" title="{{ .URL }} ({{ .MimeType }})" loading="lazy" alt="{{ .URL }} ({{ .MimeType }})">
             {{ else }}
             {{ else }}
-            <img src="{{ .URL | safeURL }}" title="{{ .URL }} ({{ .MimeType }})" loading="lazy" alt="{{ .URL }} ({{ .MimeType }})">
+            <img src="{{ .URL | untrustedURL }}" title="{{ .URL }} ({{ .MimeType }})" loading="lazy" alt="{{ .URL }} ({{ .MimeType }})">
             {{ end }}
             {{ end }}
         </div>
         </div>
         {{ end }}
         {{ end }}
 
 
         <div class="entry-enclosure-download">
         <div class="entry-enclosure-download">
-            <a href="{{ .URL | safeURL }}" title="{{ t "action.download" }}{{ if gt .Size 0 }} - {{ formatFileSize .Size }}{{ end }}" {{ if $.user.OpenExternalLinksInNewTab }}target="_blank"{{ else }}rel="noopener"{{ end }}>{{ .URL | safeURL  }}</a>
+            <a href="{{ .URL | untrustedURL }}" title="{{ t "action.download" }}{{ if gt .Size 0 }} - {{ formatFileSize .Size }}{{ end }}" {{ if $.user.OpenExternalLinksInNewTab }}target="_blank"{{ else }}rel="noopener"{{ end }}>{{ .URL }}</a>
             <small>{{ if gt .Size 0 }} - <strong>{{ formatFileSize .Size }}</strong>{{ end }}</small>
             <small>{{ if gt .Size 0 }} - <strong>{{ formatFileSize .Size }}</strong>{{ end }}</small>
         </div>
         </div>
     </div>
     </div>

+ 1 - 1
internal/template/templates/views/feed_entries.html

@@ -3,7 +3,7 @@
 {{ define "page_header"}}
 {{ define "page_header"}}
 <section class="page-header" aria-labelledby="page-header-title">
 <section class="page-header" aria-labelledby="page-header-title">
     <h1 id="page-header-title" dir="auto">
     <h1 id="page-header-title" dir="auto">
-        <a href="{{ .feed.SiteURL | safeURL  }}" title="{{ .feed.SiteURL }}" {{ if $.user.OpenExternalLinksInNewTab }}target="_blank"{{ else }}rel="noopener"{{ end }} data-original-link="{{ .user.MarkReadOnView }}">{{ .feed.Title }}</a>
+        <a href="{{ .feed.SiteURL }}" title="{{ .feed.SiteURL }}" {{ if $.user.OpenExternalLinksInNewTab }}target="_blank"{{ else }}rel="noopener"{{ end }} data-original-link="{{ .user.MarkReadOnView }}">{{ .feed.Title }}</a>
         <span aria-hidden="true">({{ .total }})</span>
         <span aria-hidden="true">({{ .total }})</span>
     </h1>
     </h1>
     <span class="sr-only">
     <span class="sr-only">