// 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 ( "io" "net/url" "slices" "strconv" "strings" "miniflux.app/v2/internal/config" "miniflux.app/v2/internal/reader/urlcleaner" "miniflux.app/v2/internal/urllib" "golang.org/x/net/html" ) var ( allowedHTMLTagsAndAttributes = map[string]map[string]struct{}{ "a": {"href": {}, "title": {}, "id": {}}, "abbr": {"title": {}}, "acronym": {"title": {}}, "aside": {}, "audio": {"src": {}}, "blockquote": {}, "b": {}, "br": {}, "caption": {}, "cite": {}, "code": {}, "dd": {"id": {}}, "del": {}, "dfn": {}, "dl": {"id": {}}, "dt": {"id": {}}, "em": {}, "figcaption": {}, "figure": {}, "h1": {"id": {}}, "h2": {"id": {}}, "h3": {"id": {}}, "h4": {"id": {}}, "h5": {"id": {}}, "h6": {"id": {}}, "hr": {}, "iframe": {"width": {}, "height": {}, "frameborder": {}, "src": {}, "allowfullscreen": {}}, "img": {"alt": {}, "title": {}, "src": {}, "srcset": {}, "sizes": {}, "width": {}, "height": {}, "fetchpriority": {}, "decoding": {}}, "ins": {}, "kbd": {}, "li": {"id": {}}, "ol": {"id": {}}, "p": {}, "picture": {}, "pre": {}, "q": {"cite": {}}, "rp": {}, "rt": {}, "rtc": {}, "ruby": {}, "s": {}, "samp": {}, "source": {"src": {}, "type": {}, "srcset": {}, "sizes": {}, "media": {}}, "strong": {}, "sub": {}, "sup": {"id": {}}, "table": {}, "td": {"rowspan": {}, "colspan": {}}, "tfoot": {}, "th": {"rowspan": {}, "colspan": {}}, "thead": {}, "time": {"datetime": {}}, "tr": {}, "u": {}, "ul": {"id": {}}, "var": {}, "video": {"poster": {}, "height": {}, "width": {}, "src": {}}, "wbr": {}, // MathML: https://w3c.github.io/mathml-core/ and https://developer.mozilla.org/en-US/docs/Web/MathML/Reference/Element "annotation": {}, "annotation-xml": {}, "maction": {}, "math": {"xmlns": {}}, "merror": {}, "mfrac": {}, "mi": {}, "mmultiscripts": {}, "mn": {}, "mo": {}, "mover": {}, "mpadded": {}, "mphantom": {}, "mprescripts": {}, "mroot": {}, "mrow": {}, "ms": {}, "mspace": {}, "msqrt": {}, "mstyle": {}, "msub": {}, "msubsup": {}, "msup": {}, "mtable": {}, "mtd": {}, "mtext": {}, "mtr": {}, "munder": {}, "munderover": {}, "semantics": {}, } iframeAllowList = map[string]struct{}{ "bandcamp.com": {}, "cdn.embedly.com": {}, "dailymotion.com": {}, "open.spotify.com": {}, "player.bilibili.com": {}, "player.twitch.tv": {}, "player.vimeo.com": {}, "soundcloud.com": {}, "vk.com": {}, "w.soundcloud.com": {}, "youtube-nocookie.com": {}, "youtube.com": {}, } blockedResourceURLSubstrings = []string{ "api.flattr.com", "feeds.feedburner.com", "feedsportal.com", "pinterest.com/pin/create/button/", "stats.wordpress.com", "twitter.com/intent/tweet", "twitter.com/share", "www.facebook.com/sharer.php", "www.linkedin.com/shareArticle", } validURISchemes = map[string]struct{}{ "apt": {}, "bitcoin": {}, "callto": {}, "dav": {}, "davs": {}, "ed2k": {}, "facetime": {}, "feed": {}, "ftp": {}, "geo": {}, "git": {}, "gopher": {}, "http": {}, "https": {}, "irc": {}, "irc6": {}, "ircs": {}, "itms-apps": {}, "itms": {}, "magnet": {}, "mailto": {}, "news": {}, "nntp": {}, "rtmp": {}, "sftp": {}, "sip": {}, "sips": {}, "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 } blockedTags = map[string]struct{}{ "noscript": {}, "script": {}, "style": {}, } dataAttributeAllowedPrefixes = []string{ "data:image/avif", "data:image/apng", "data:image/png", "data:image/svg", "data:image/svg+xml", "data:image/jpg", "data:image/jpeg", "data:image/gif", "data:image/webp", } ) type SanitizerOptions struct { OpenLinksInNewTab bool } func SanitizeHTMLWithDefaultOptions(baseURL, rawHTML string) string { return SanitizeHTML(baseURL, rawHTML, &SanitizerOptions{ OpenLinksInNewTab: true, }) } func SanitizeHTML(baseURL, rawHTML string, sanitizerOptions *SanitizerOptions) string { var buffer strings.Builder var tagStack []string var parentTag string var blockedStack []string // Errors are a non-issue, so they're handled later in the function. parsedBaseUrl, _ := url.Parse(baseURL) tokenizer := html.NewTokenizer(strings.NewReader(rawHTML)) for { if tokenizer.Next() == html.ErrorToken { err := tokenizer.Err() if err == io.EOF { return buffer.String() } return "" } token := tokenizer.Token() // Note: MathML elements are not fully supported by golang.org/x/net/html. // See https://github.com/golang/net/blob/master/html/atom/gen.go // and https://github.com/golang/net/blob/master/html/atom/table.go tagName := token.Data if tagName == "" { continue } switch token.Type { case html.TextToken: if len(blockedStack) > 0 { continue } // An iframe element never has fallback content. // See https://www.w3.org/TR/2010/WD-html5-20101019/the-iframe-element.html#the-iframe-element if parentTag == "iframe" { continue } buffer.WriteString(token.String()) case html.StartTagToken: parentTag = tagName if isPixelTracker(tagName, token.Attr) { continue } if isBlockedTag(tagName) || slices.ContainsFunc(token.Attr, func(attr html.Attribute) bool { return attr.Key == "hidden" }) { blockedStack = append(blockedStack, tagName) continue } if len(blockedStack) == 0 && isValidTag(tagName) { attrNames, htmlAttributes := sanitizeAttributes(parsedBaseUrl, baseURL, tagName, token.Attr, sanitizerOptions) if hasRequiredAttributes(tagName, attrNames) { if len(attrNames) > 0 { // Rewrite the start tag with allowed attributes. buffer.WriteString("<" + tagName + " " + htmlAttributes + ">") } else { // Rewrite the start tag without any attributes. buffer.WriteString("<" + tagName + ">") } tagStack = append(tagStack, tagName) } } case html.EndTagToken: if len(blockedStack) == 0 { if isValidTag(tagName) && slices.Contains(tagStack, tagName) { buffer.WriteString("") } } else { if blockedStack[len(blockedStack)-1] == tagName { blockedStack = blockedStack[:len(blockedStack)-1] } } case html.SelfClosingTagToken: if isPixelTracker(tagName, token.Attr) { continue } if len(blockedStack) == 0 && isValidTag(tagName) { attrNames, htmlAttributes := sanitizeAttributes(parsedBaseUrl, baseURL, tagName, token.Attr, sanitizerOptions) if hasRequiredAttributes(tagName, attrNames) { if len(attrNames) > 0 { buffer.WriteString("<" + tagName + " " + htmlAttributes + "/>") } else { buffer.WriteString("<" + tagName + "/>") } } } } } } func sanitizeAttributes(parsedBaseUrl *url.URL, baseURL, tagName string, attributes []html.Attribute, sanitizerOptions *SanitizerOptions) ([]string, string) { var htmlAttrs, attrNames []string var err error var isImageLargerThanLayout bool var isAnchorLink bool if tagName == "img" { imgWidth := getIntegerAttributeValue("width", attributes) isImageLargerThanLayout = imgWidth > 750 } for _, attribute := range attributes { value := attribute.Val if !isValidAttribute(tagName, attribute.Key) { continue } if tagName == "math" && attribute.Key == "xmlns" && value != "http://www.w3.org/1998/Math/MathML" { value = "http://www.w3.org/1998/Math/MathML" } if tagName == "img" && attribute.Key == "fetchpriority" { if !isValidFetchPriorityValue(value) { continue } } if tagName == "img" && attribute.Key == "decoding" { if !isValidDecodingValue(value) { continue } } if (tagName == "img" || tagName == "source") && attribute.Key == "srcset" { value = sanitizeSrcsetAttr(baseURL, value) } if tagName == "img" && (attribute.Key == "width" || attribute.Key == "height") { if isImageLargerThanLayout || !isPositiveInteger(value) { continue } } if isExternalResourceAttribute(attribute.Key) { switch { case tagName == "iframe": if !isValidIframeSource(attribute.Val) { continue } value = rewriteIframeURL(attribute.Val) case tagName == "img" && attribute.Key == "src" && isValidDataAttribute(attribute.Val): value = attribute.Val case tagName == "a" && attribute.Key == "href" && strings.HasPrefix(attribute.Val, "#"): value = attribute.Val isAnchorLink = true default: value, err = urllib.AbsoluteURL(baseURL, value) if err != nil { continue } if !hasValidURIScheme(value) || isBlockedResource(value) { continue } // TODO use feedURL instead of baseURL twice. parsedValueUrl, _ := url.Parse(value) if cleanedURL, err := urlcleaner.RemoveTrackingParameters(parsedBaseUrl, parsedBaseUrl, parsedValueUrl); err == nil { value = cleanedURL } } } attrNames = append(attrNames, attribute.Key) htmlAttrs = append(htmlAttrs, attribute.Key+`="`+html.EscapeString(value)+`"`) } if !isAnchorLink { extraAttrNames, extraHTMLAttributes := getExtraAttributes(tagName, sanitizerOptions) if len(extraAttrNames) > 0 { attrNames = append(attrNames, extraAttrNames...) htmlAttrs = append(htmlAttrs, extraHTMLAttributes...) } } return attrNames, strings.Join(htmlAttrs, " ") } func getExtraAttributes(tagName string, sanitizerOptions *SanitizerOptions) ([]string, []string) { switch tagName { case "a": attributeNames := []string{"rel", "referrerpolicy"} htmlAttributes := []string{`rel="noopener noreferrer"`, `referrerpolicy="no-referrer"`} if sanitizerOptions.OpenLinksInNewTab { attributeNames = append(attributeNames, "target") htmlAttributes = append(htmlAttributes, `target="_blank"`) } return attributeNames, htmlAttributes case "video", "audio": return []string{"controls"}, []string{"controls"} case "iframe": return []string{"sandbox", "loading"}, []string{`sandbox="allow-scripts allow-same-origin allow-popups allow-popups-to-escape-sandbox"`, `loading="lazy"`} case "img": return []string{"loading"}, []string{`loading="lazy"`} default: return nil, nil } } func isValidTag(tagName string) bool { _, ok := allowedHTMLTagsAndAttributes[tagName] return ok } func isValidAttribute(tagName, attributeName string) bool { if attributes, ok := allowedHTMLTagsAndAttributes[tagName]; ok { _, allowed := attributes[attributeName] return allowed } return false } func isExternalResourceAttribute(attribute string) bool { switch attribute { case "src", "href", "poster", "cite": return true default: return false } } func isPixelTracker(tagName string, attributes []html.Attribute) bool { if tagName != "img" { return false } hasHeight := false hasWidth := false for _, attribute := range attributes { if attribute.Val == "1" { switch attribute.Key { case "height": hasHeight = true case "width": hasWidth = true } } } return hasHeight && hasWidth } func hasRequiredAttributes(tagName string, attributes []string) bool { switch tagName { case "a": return slices.Contains(attributes, "href") case "iframe": return slices.Contains(attributes, "src") case "source", "img": return slices.Contains(attributes, "src") || slices.Contains(attributes, "srcset") default: return true } } // See https://www.iana.org/assignments/uri-schemes/uri-schemes.xhtml func hasValidURIScheme(absoluteURL string) bool { colonIndex := strings.IndexByte(absoluteURL, ':') // Scheme must exist (colonIndex > 0). An empty scheme (e.g. ":foo") is not allowed. if colonIndex <= 0 { return false } scheme := absoluteURL[:colonIndex] _, ok := validURISchemes[strings.ToLower(scheme)] return ok } func isBlockedResource(absoluteURL string) bool { return slices.ContainsFunc(blockedResourceURLSubstrings, func(element string) bool { return strings.Contains(absoluteURL, element) }) } func isValidIframeSource(iframeSourceURL string) bool { iframeSourceDomain := strings.TrimPrefix(urllib.Domain(iframeSourceURL), "www.") if _, ok := iframeAllowList[iframeSourceDomain]; ok { return true } if ytDomain := config.Opts.YouTubeEmbedDomain(); ytDomain != "" && iframeSourceDomain == strings.TrimPrefix(ytDomain, "www.") { return true } if invidiousInstance := config.Opts.InvidiousInstance(); invidiousInstance != "" && iframeSourceDomain == strings.TrimPrefix(invidiousInstance, "www.") { return true } return false } func rewriteIframeURL(link string) string { u, err := url.Parse(link) if err != nil { return link } switch strings.TrimPrefix(u.Hostname(), "www.") { case "youtube.com": if strings.HasPrefix(u.Path, "/embed/") { if len(u.RawQuery) > 0 { return config.Opts.YouTubeEmbedUrlOverride() + strings.TrimPrefix(u.Path, "/embed/") + "?" + u.RawQuery } return config.Opts.YouTubeEmbedUrlOverride() + strings.TrimPrefix(u.Path, "/embed/") } case "player.vimeo.com": // See https://help.vimeo.com/hc/en-us/articles/12426260232977-About-Player-parameters if strings.HasPrefix(u.Path, "/video/") { if len(u.RawQuery) > 0 { return link + "&dnt=1" } return link + "?dnt=1" } } return link } func isBlockedTag(tagName string) bool { _, ok := blockedTags[tagName] return ok } func sanitizeSrcsetAttr(baseURL, value string) string { imageCandidates := ParseSrcSetAttribute(value) for _, imageCandidate := range imageCandidates { if absoluteURL, err := urllib.AbsoluteURL(baseURL, imageCandidate.ImageURL); err == nil { imageCandidate.ImageURL = absoluteURL } } return imageCandidates.String() } func isValidDataAttribute(value string) bool { for _, prefix := range dataAttributeAllowedPrefixes { if strings.HasPrefix(value, prefix) { return true } } return false } func isPositiveInteger(value string) bool { if value == "" { return false } if number, err := strconv.Atoi(value); err == nil { return number > 0 } return false } func getIntegerAttributeValue(name string, attributes []html.Attribute) int { for _, attribute := range attributes { if attribute.Key == name { number, _ := strconv.Atoi(attribute.Val) return number } } return 0 } func isValidFetchPriorityValue(value string) bool { allowedValues := []string{"high", "low", "auto"} return slices.Contains(allowedValues, value) } func isValidDecodingValue(value string) bool { allowedValues := []string{"sync", "async", "auto"} return slices.Contains(allowedValues, value) }