8000 [pull] main from miniflux:main by pull[bot] · Pull Request #223 · suhaoGe/miniflux · GitHub
[go: up one dir, main page]

Skip to content
Merged 8000
Show file tree
Hide file tree
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: 100 additions & 102 deletions internal/reader/sanitizer/sanitizer.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
package sanitizer // import "miniflux.app/v2/internal/reader/sanitizer"

import (
"io"
"net/url"
"slices"
"strconv"
Expand Down Expand Up @@ -200,119 +199,138 @@ var (
}
)

// SanitizerOptions holds options for the HTML sanitizer.
type SanitizerOptions struct {
OpenLinksInNewTab bool
}

// SanitizeHTML takes raw HTML input and removes any disallowed tags and attributes.
func SanitizeHTML(baseURL, rawHTML string, sanitizerOptions *SanitizerOptions) string {
var tagStack []string
var parentTag string
var blockedStack []string
var buffer strings.Builder

// Educated guess about how big the sanitized HTML will be,
// to reduce the amount of buffer re-allocations in this function.
estimatedRatio := len(rawHTML) * 3 / 4
buffer.Grow(estimatedRatio)

// Errors are a non-issue, so they're handled later in the function.
// We need to surround `rawHTML` with body tags so that html.Parse
// will consider it a valid html document.
doc, err := html.Parse(strings.NewReader("<body>" + rawHTML + "</body>"))
if err != nil {
return ""
}

/* The structure of `doc` is always:
<html>
<head>...</head>
<body>..</body>
</html>
*/
body := doc.FirstChild.FirstChild.NextSibling

// Errors are a non-issue, so they're handled in filterAndRenderHTML
parsedBaseUrl, _ := url.Parse(baseURL)
for c := body.FirstChild; c != nil; c = c.NextSibling {
filterAndRenderHTML(&buffer, c, parsedBaseUrl, sanitizerOptions)
}

tokenizer := html.NewTokenizer(strings.NewReader(rawHTML))
for {
if tokenizer.Next() == html.ErrorToken {
err := tokenizer.Err()
if err == io.EOF {
return buffer.String()
}
return buffer.String()
}

return ""
func isHidden(n *html.Node) bool {
for _, attr := range n.Attr {
if strings.ToLower(attr.Key) == "hidden" {
return true
}
}
return false
}

token := tokenizer.Token()
func shouldIgnoreTag(n *html.Node, tag string) bool {
if isPixelTracker(tag, n.Attr) {
return true
}
if isBlockedTag(tag) {
return true
}
if isHidden(n) {
return true
}

// 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
}
return false
}

switch token.Type {
case html.TextToken:
if len(blockedStack) > 0 {
continue
}
func isSelfContainedTag(tag string) bool {
switch tag {
case "area", "base", "br", "col", "embed", "hr", "img", "input",
"link", "meta", "param", "source", "track", "wbr":
return true
}
return false
}

// 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
}
func filterAndRenderHTMLChildren(buf *strings.Builder, n *html.Node, parsedBaseUrl *url.URL, sanitizerOptions *SanitizerOptions) {
for c := n.FirstChild; c != nil; c = c.NextSibling {
filterAndRenderHTML(buf, c, parsedBaseUrl, sanitizerOptions)
}
}

buffer.WriteString(token.String())
case html.StartTagToken:
if len(blockedStack) != 0 {
continue
}
func filterAndRenderHTML(buf *strings.Builder, n *html.Node, parsedBaseUrl *url.URL, sanitizerOptions *SanitizerOptions) {
if n == nil {
return
}

parentTag = tagName
switch n.Type {
case html.TextNode:
buf.WriteString(html.EscapeString(n.Data))
case html.ElementNode:
tag := strings.ToLower(n.Data)
if shouldIgnoreTag(n, tag) {
return
}

if isPixelTracker(tagName, token.Attr) {
continue
}
_, ok := allowedHTMLTagsAndAttributes[tag]
if !ok {
// The tag isn't allowed, but we're still interested in its content
filterAndRenderHTMLChildren(buf, n, parsedBaseUrl, sanitizerOptions)
return
}

if isBlockedTag(tagName) || slices.ContainsFunc(token.Attr, func(attr html.Attribute) bool { return attr.Key == "hidden" }) {
blockedStack = append(blockedStack, tagName)
continue
}
attrNames, htmlAttributes := sanitizeAttributes(parsedBaseUrl, tag, n.Attr, sanitizerOptions)
if !hasRequiredAttributes(tag, attrNames) {
// The tag doesn't have every required attributes but we're still interested in its content
filterAndRenderHTMLChildren(buf, n, parsedBaseUrl, sanitizerOptions)
return
}
buf.WriteString("<")
buf.WriteString(n.Data)
if len(attrNames) > 0 {
buf.WriteString(" " + htmlAttributes)
}
buf.WriteString(">")

if isAllowedTag(tagName) {
attrNames, htmlAttributes := sanitizeAttributes(parsedBaseUrl, 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 isAllowedTag(tagName) && slices.Contains(tagStack, tagName) {
buffer.WriteString("</" + tagName + ">")
}
} 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 && isAllowedTag(tagName) {
attrNames, htmlAttributes := sanitizeAttributes(parsedBaseUrl, tagName, token.Attr, sanitizerOptions)
if hasRequiredAttributes(tagName, attrNames) {
if len(attrNames) > 0 {
buffer.WriteString("<" + tagName + " " + htmlAttributes + "/>")
} else {
buffer.WriteString("<" + tagName + "/>")
}
}
}
if isSelfContainedTag(tag) {
return
}

if tag != "iframe" {
// iframes aren't allowed to have child nodes.
filterAndRenderHTMLChildren(buf, n, parsedBaseUrl, sanitizerOptions)
}

buf.WriteString("</")
buf.WriteString(n.Data)
buf.WriteString(">")
case html.DocumentNode:
filterAndRenderHTMLChildren(buf, n, parsedBaseUrl, sanitizerOptions)
default:
}
}

func sanitizeAttributes(parsedBaseUrl *url.URL, tagName string, attributes []html.Attribute, sanitizerOptions *SanitizerOptions) ([]string, string) {
htmlAttrs := make([]string, 0, len(attributes))
attrNames := make([]string, 0, len(attributes))

var err error
var isAnchorLink bool
var isYouTubeEmbed bool
Expand Down Expand Up @@ -345,11 +363,6 @@ func sanitizeAttributes(parsedBaseUrl *url.URL, tagName string, attributes []htm
if !isPositiveInteger(value) {
continue
}

// Discard width and height attributes when width is larger than Miniflux layout (750px)
if imgWidth := getIntegerAttributeValue("width", attributes); imgWidth > 750 {
continue
}
case "srcset":
value = sanitizeSrcsetAttr(parsedBaseUrl, value)
}
Expand Down Expand Up @@ -444,11 +457,6 @@ func getExtraAttributes(tagName string, isYouTubeEmbed bool, sanitizerOptions *S
}
}

func isAllowedTag(tagName string) bool {
_, ok := allowedHTMLTagsAndAttributes[tagName]
return ok
}

func isValidAttribute(tagName, attributeName string) bool {
if attributes, ok := allowedHTMLTagsAndAttributes[tagName]; ok {
return slices.Contains(attributes, attributeName)
Expand Down Expand Up @@ -606,16 +614,6 @@ func isPositiveInteger(value string) bool {
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 {
switch value {
case "high", "low", "auto":
Expand Down
24 changes: 7 additions & 17 deletions internal/reader/sanitizer/sanitizer_test.go
DAC0
Original file line number Diff line number Diff line change
Expand Up @@ -79,16 +79,6 @@ func TestImgWithWidthAndHeightAttribute(t *testing.T) {
}
}

func TestImgWithWidthAttributeLargerThanMinifluxLayout(t *testing.T) {
input := `<img src="https://example.org/image.png" width="1200" height="675">`
expected := `<img src="https://example.org/image.png" loading="lazy">`
output := sanitizeHTMLWithDefaultOptions("http://example.org/", input)

if output != expected {
t.Errorf(`Wrong output: %s`, output)
}
}

func TestImgWithIncorrectWidthAndHeightAttribute(t *testing.T) {
input := `<img src="https://example.org/image.png" width="10px" height="20px">`
expected := `<img src="https://example.org/image.png" loading="lazy">`
Expand Down Expand Up @@ -295,7 +285,7 @@ func TestSourceWithSrcsetAndMedia(t *testing.T) {

func TestMediumImgWithSrcset(t *testing.T) {
input := `<img alt="Image for post" class="t u v ef aj" src="https://miro.medium.com/max/5460/1*aJ9JibWDqO81qMfNtqgqrw.jpeg" srcset="https://miro.medium.com/max/552/1*aJ9JibWDqO81qMfNtqgqrw.jpeg 276w, https://miro.medium.com/max/1000/1*aJ9JibWDqO81qMfNtqgqrw.jpeg 500w" sizes="500px" width="2730" height="3407">`
expected := `<img alt="Image for post" src="https://miro.medium.com/max/5460/1*aJ9JibWDqO81qMfNtqgqrw.jpeg" srcset="https://miro.medium.com/max/552/1*aJ9JibWDqO81qMfNtqgqrw.jpeg 276w, https://miro.medium.com/max/1000/1*aJ9JibWDqO81qMfNtqgqrw.jpeg 500w" sizes="500px" loading="lazy">`
expected := `<img alt="Image for post" src="https://miro.medium.com/max/5460/1*aJ9JibWDqO81qMfNtqgqrw.jpeg" srcset="https://miro.medium.com/max/552/1*aJ9JibWDqO81qMfNtqgqrw.jpeg 276w, https://miro.medium.com/max/1000/1*aJ9JibWDqO81qMfNtqgqrw.jpeg 500w" sizes="500px" width="2730" height="3407" loading="lazy">`
output := sanitizeHTMLWithDefaultOptions("http://example.org/", input)

if output != expected {
Expand All @@ -304,7 +294,7 @@ func TestMediumImgWithSrcset(t *testing.T) {
}

func TestSelfClosingTags(t *testing.T) {
input := `<p>This <br> is a <strong>text</strong> <br/>with an image: <img src="http://example.org/" alt="Test" loading="lazy"/>.</p>`
input := `<p>This <br> is a <strong>text</strong> <br>with an image: <img src="http://example.org/" alt="Test" loading="lazy">.</p>`
output := sanitizeHTMLWithDefaultOptions("http://example.org/", input)

if input != output {
Expand All @@ -322,8 +312,8 @@ func TestTable(t *testing.T) {
}

func TestRelativeURL(t *testing.T) {
input := `This <a href="/test.html">link is relative</a> and this image: <img src="../folder/image.png"/>`
expected := `This <a href="http://example.org/test.html" rel="noopener noreferrer" referrerpolicy="no-referrer" target="_blank">link is relative</a> and this image: <img src="http://example.org/folder/image.png" loading="lazy"/>`
input := `This <a href="/test.html">link is relative</a> and this image: <img src="../folder/image.png">`
expected := `This <a href="http://example.org/test.html" rel="noopener noreferrer" referrerpolicy="no-referrer" target="_blank">link is relative</a> and this image: <img src="http://example.org/folder/image.png" loading="lazy">`
output := sanitizeHTMLWithDefaultOptions("http://example.org/", input)

if expected != output {
Expand Down Expand Up @@ -798,8 +788,8 @@ func TestXmlEntities(t *testing.T) {
}

func TestEspaceAttributes(t *testing.T) {
input := `<td rowspan="<b>test</b>">test</td>`
expected := `<td rowspan="&lt;b&gt;test&lt;/b&gt;">test</td>`
input := `<td rowspan="<b>injection</b>">text</td>`
expected := `text`
output := sanitizeHTMLWithDefaultOptions("http://example.org/", input)

if expected != output {
Expand Down Expand Up @@ -978,7 +968,7 @@ func TestHiddenParagraph(t *testing.T) {

func TestAttributesAreStripped(t *testing.T) {
input := `<p style="color: red;">Some text.<hr style="color: blue"/>Test.</p>`
expected := `<p>Some text.<hr/>Test.</p>`
expected := `<p>Some text.</p><hr>Test.<p></p>`

output := sanitizeHTMLWithDefaultOptions("http://example.org/", input)
if expected != output {
Expand Down
Loading
0