rss email digests over ssh because you're a cool kid herald.dunkirk.sh
go rss rss-reader ssh charm
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

Merge pull request #3 from taciturnaxolotl/claude/safe-html-inline-feeds-TLr9U

authored by

Kieran Klukas and committed by
GitHub
a009d02a 6fe82b5e

+177 -4
+72 -3
email/render.go
··· 6 6 htmltemplate "html/template" 7 7 texttemplate "text/template" 8 8 "time" 9 + 10 + "github.com/microcosm-cc/bluemonday" 9 11 ) 10 12 11 13 //go:embed templates/* ··· 30 32 Published time.Time 31 33 } 32 34 35 + // templateFeedItem is used for template rendering with sanitized HTML content 36 + type templateFeedItem struct { 37 + Title string 38 + Link string 39 + Content string // Original content for text template 40 + SanitizedContent htmltemplate.HTML // Sanitized HTML for HTML template 41 + Published time.Time 42 + } 43 + 44 + // templateFeedGroup is used for template rendering with sanitized items 45 + type templateFeedGroup struct { 46 + FeedName string 47 + FeedURL string 48 + Items []templateFeedItem 49 + } 50 + 51 + // sanitizeHTML sanitizes HTML content, allowing safe tags while stripping styles and unsafe elements 52 + func sanitizeHTML(html string) string { 53 + return policy.Sanitize(html) 54 + } 55 + 33 56 var ( 34 57 htmlTmpl *htmltemplate.Template 35 58 textTmpl *texttemplate.Template 59 + policy *bluemonday.Policy 36 60 ) 37 61 38 62 func init() { ··· 45 69 if err != nil { 46 70 panic("failed to parse text template: " + err.Error()) 47 71 } 72 + 73 + // Initialize HTML sanitization policy 74 + // UGCPolicy allows safe HTML tags but strips styles and unsafe elements 75 + // This prevents XSS attacks while allowing basic formatting 76 + policy = bluemonday.UGCPolicy() 48 77 } 49 78 50 79 func RenderDigest(data *DigestData, inline bool, daysUntilExpiry int, showUrgentBanner, showWarningBanner bool) (html string, text string, err error) { 51 - tmplData := struct { 80 + // Convert FeedGroups to templateFeedGroups with sanitized HTML content 81 + sanitizedGroups := make([]templateFeedGroup, len(data.FeedGroups)) 82 + for i, group := range data.FeedGroups { 83 + sanitizedItems := make([]templateFeedItem, len(group.Items)) 84 + for j, item := range group.Items { 85 + sanitizedItems[j] = templateFeedItem{ 86 + Title: item.Title, 87 + Link: item.Link, 88 + Content: item.Content, 89 + SanitizedContent: htmltemplate.HTML(sanitizeHTML(item.Content)), // #nosec G203 -- Content is sanitized by bluemonday before conversion 90 + Published: item.Published, 91 + } 92 + } 93 + sanitizedGroups[i] = templateFeedGroup{ 94 + FeedName: group.FeedName, 95 + FeedURL: group.FeedURL, 96 + Items: sanitizedItems, 97 + } 98 + } 99 + 100 + // Prepare template data for HTML template (with sanitized content) 101 + htmlTmplData := struct { 102 + ConfigName string 103 + TotalItems int 104 + FeedGroups []templateFeedGroup 105 + Inline bool 106 + DaysUntilExpiry int 107 + ShowUrgentBanner bool 108 + ShowWarningBanner bool 109 + }{ 110 + ConfigName: data.ConfigName, 111 + TotalItems: data.TotalItems, 112 + FeedGroups: sanitizedGroups, 113 + Inline: inline, 114 + DaysUntilExpiry: daysUntilExpiry, 115 + ShowUrgentBanner: showUrgentBanner, 116 + ShowWarningBanner: showWarningBanner, 117 + } 118 + 119 + // Prepare template data for text template (with original content) 120 + textTmplData := struct { 52 121 *DigestData 53 122 Inline bool 54 123 DaysUntilExpiry int ··· 64 133 65 134 var htmlBuf, textBuf bytes.Buffer 66 135 67 - if err = htmlTmpl.Execute(&htmlBuf, tmplData); err != nil { 136 + if err = htmlTmpl.Execute(&htmlBuf, htmlTmplData); err != nil { 68 137 return "", "", err 69 138 } 70 139 71 - if err = textTmpl.Execute(&textBuf, tmplData); err != nil { 140 + if err = textTmpl.Execute(&textBuf, textTmplData); err != nil { 72 141 return "", "", err 73 142 } 74 143
+95
email/render_test.go
··· 1 + package email 2 + 3 + import ( 4 + "strings" 5 + "testing" 6 + "time" 7 + ) 8 + 9 + func TestRenderDigest_HTMLNotEscaped(t *testing.T) { 10 + // Create test data with HTML content 11 + data := &DigestData{ 12 + ConfigName: "Test Config", 13 + TotalItems: 1, 14 + FeedGroups: []FeedGroup{ 15 + { 16 + FeedName: "Test Feed", 17 + FeedURL: "https://example.com/feed", 18 + Items: []FeedItem{ 19 + { 20 + Title: "Test Article", 21 + Link: "https://example.com/article", 22 + Content: "<p>This is a <strong>test</strong> article with <a href='https://example.com'>a link</a>.</p>", 23 + Published: time.Now(), 24 + }, 25 + }, 26 + }, 27 + }, 28 + } 29 + 30 + // Render with inline mode enabled 31 + htmlOutput, _, err := RenderDigest(data, true, 30, false, false) 32 + if err != nil { 33 + t.Fatalf("RenderDigest failed: %v", err) 34 + } 35 + 36 + // Debug: print actual output 37 + t.Logf("HTML Output:\n%s", htmlOutput) 38 + 39 + // Verify HTML is NOT escaped (should contain actual tags, not &lt; entities) 40 + if strings.Contains(htmlOutput, "&lt;p&gt;") { 41 + t.Error("HTML is being escaped - found &lt;p&gt; instead of <p>") 42 + } 43 + if strings.Contains(htmlOutput, "&lt;strong&gt;") { 44 + t.Error("HTML is being escaped - found &lt;strong&gt; instead of <strong>") 45 + } 46 + 47 + // Verify HTML tags are present (not escaped) 48 + if !strings.Contains(htmlOutput, "<p>This is a <strong>test</strong>") { 49 + t.Error("HTML tags are not being rendered - content appears to be escaped") 50 + } 51 + } 52 + 53 + func TestRenderDigest_UnsafeHTMLStripped(t *testing.T) { 54 + // Create test data with unsafe HTML content 55 + data := &DigestData{ 56 + ConfigName: "Test Config", 57 + TotalItems: 1, 58 + FeedGroups: []FeedGroup{ 59 + { 60 + FeedName: "Test Feed", 61 + FeedURL: "https://example.com/feed", 62 + Items: []FeedItem{ 63 + { 64 + Title: "Test Article", 65 + Link: "https://example.com/article", 66 + Content: "<p>Safe content</p><script>alert('xss')</script><p style='color:red'>No styles</p>", 67 + Published: time.Now(), 68 + }, 69 + }, 70 + }, 71 + }, 72 + } 73 + 74 + // Render with inline mode enabled 75 + htmlOutput, _, err := RenderDigest(data, true, 30, false, false) 76 + if err != nil { 77 + t.Fatalf("RenderDigest failed: %v", err) 78 + } 79 + 80 + // Debug: print actual output 81 + t.Logf("HTML Output:\n%s", htmlOutput) 82 + 83 + // Verify script tags are removed 84 + if strings.Contains(htmlOutput, "<script>") { 85 + t.Error("Unsafe <script> tags were not stripped") 86 + } 87 + if strings.Contains(htmlOutput, "alert('xss')") { 88 + t.Error("Script content was not removed") 89 + } 90 + 91 + // Verify safe content remains 92 + if !strings.Contains(htmlOutput, "<p>Safe content</p>") { 93 + t.Error("Safe HTML content was incorrectly removed") 94 + } 95 + }
+1 -1
email/templates/digest.html
··· 67 67 {{range .Items}} 68 68 <div> 69 69 <h1><a href="{{.Link}}">{{.Title}}</a></h1> 70 - <div>{{.Content}}</div> 70 + <div>{{.SanitizedContent}}</div> 71 71 </div> 72 72 <hr /> 73 73 {{end}}
+3
go.mod
··· 27 27 github.com/andybalholm/cascadia v1.3.3 // indirect 28 28 github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect 29 29 github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect 30 + github.com/aymerick/douceur v0.2.0 // indirect 30 31 github.com/charmbracelet/bubbletea v1.3.10 // indirect 31 32 github.com/charmbracelet/colorprofile v0.4.1 // indirect 32 33 github.com/charmbracelet/keygen v0.5.4 // indirect ··· 44 45 github.com/creack/pty v1.1.24 // indirect 45 46 github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect 46 47 github.com/go-logfmt/logfmt v0.6.1 // indirect 48 + github.com/gorilla/css v1.0.1 // indirect 47 49 github.com/inconshreveable/mousetrap v1.1.0 // indirect 48 50 github.com/json-iterator/go v1.1.12 // indirect 49 51 github.com/kr/fs v0.1.0 // indirect ··· 51 53 github.com/mattn/go-isatty v0.0.20 // indirect 52 54 github.com/mattn/go-localereader v0.0.1 // indirect 53 55 github.com/mattn/go-runewidth v0.0.19 // indirect 56 + github.com/microcosm-cc/bluemonday v1.0.27 // indirect 54 57 github.com/mmcdole/goxpp v1.1.1 // indirect 55 58 github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 56 59 github.com/modern-go/reflect2 v1.0.2 // indirect
+6
go.sum
··· 12 12 github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= 13 13 github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY= 14 14 github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= 15 + github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= 16 + github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= 15 17 github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= 16 18 github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= 17 19 github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk= ··· 68 70 github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= 69 71 github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 70 72 github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 73 + github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= 74 + github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= 71 75 github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= 72 76 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 73 77 github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= ··· 88 92 github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= 89 93 github.com/mattn/go-sqlite3 v1.14.33 h1:A5blZ5ulQo2AtayQ9/limgHEkFreKj1Dv226a1K73s0= 90 94 github.com/mattn/go-sqlite3 v1.14.33/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= 95 + github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= 96 + github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= 91 97 github.com/mmcdole/gofeed v1.3.0 h1:5yn+HeqlcvjMeAI4gu6T+crm7d0anY85+M+v6fIFNG4= 92 98 github.com/mmcdole/gofeed v1.3.0/go.mod h1:9TGv2LcJhdXePDzxiuMnukhV2/zb6VtnZt1mS+SjkLE= 93 99 github.com/mmcdole/goxpp v1.1.1 h1:RGIX+D6iQRIunGHrKqnA2+700XMCnNv0bAOOv5MUhx8=