Skip to content

Commit 47ea5e4

Browse files
Thomas StrombergThomas Stromberg
authored andcommitted
<br> handling and whitespace
1 parent 2f2212f commit 47ea5e4

File tree

2 files changed

+124
-3
lines changed

2 files changed

+124
-3
lines changed

email/templates.go

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ func (s *Sender) formatNotificationBody(sub *notifier.Subscription, thread *noti
1616
b.WriteString("<meta charset=\"utf-8\">\n")
1717
b.WriteString("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n")
1818
b.WriteString("<style>\n")
19-
b.WriteString("body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; max-width: 800px; margin: 0 auto; padding: 20px; background: #fff; }\n")
19+
b.WriteString("body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; max-width: 800px; margin: 0 auto; padding: 12px 20px; background: #fff; }\n")
2020
b.WriteString(".post { padding: 24px 0; border-bottom: 2px solid #e67e22; }\n")
2121
b.WriteString(".post:last-of-type { border-bottom: none; padding-bottom: 0; }\n")
2222
b.WriteString(".post:first-of-type { padding-top: 0; }\n")
@@ -28,6 +28,7 @@ func (s *Sender) formatNotificationBody(sub *notifier.Subscription, thread *noti
2828
b.WriteString(".content { margin: 15px 0; }\n")
2929
b.WriteString(".content img { max-width: 100%; height: auto; margin: 10px 0; display: block; }\n")
3030
b.WriteString(".content blockquote { border-left: 3px solid #ddd; padding-left: 15px; margin: 10px 0; color: #666; font-size: 0.95em; }\n")
31+
b.WriteString(".content hr { border: none; border-top: 1px solid #ddd; margin: 15px 0; }\n")
3132
b.WriteString(".footer { margin-top: 24px; padding-top: 12px; font-size: 0.9em; color: #7f8c8d; }\n")
3233
b.WriteString(".footer.with-border { border-top: 1px solid #ddd; }\n")
3334
b.WriteString(".footer a { color: #7f8c8d; text-decoration: underline; margin: 0 8px; }\n")
@@ -41,6 +42,7 @@ func (s *Sender) formatNotificationBody(sub *notifier.Subscription, thread *noti
4142
b.WriteString(".timestamp { color: #a0a0a0; }\n")
4243
b.WriteString(".content blockquote { border-left-color: #444; color: #b0b0b0; }\n")
4344
b.WriteString(".content img { opacity: 0.9; }\n")
45+
b.WriteString(".content hr { border-top-color: #444; }\n")
4446
b.WriteString(".footer { color: #a0a0a0; }\n")
4547
b.WriteString(".footer.with-border { border-top-color: #444; }\n")
4648
b.WriteString(".footer a { color: #a0a0a0; }\n")
@@ -65,8 +67,8 @@ func (s *Sender) formatNotificationBody(sub *notifier.Subscription, thread *noti
6567

6668
b.WriteString("<div class=\"content\">\n")
6769
// SECURITY: HTML content from forum posts is untrusted user input.
68-
// We sanitize it to allow only safe tags (img, blockquote, p, br, b, i, em, strong)
69-
// and safe attributes (src, alt for images) to prevent XSS and phishing.
70+
// We sanitize it to allow only safe tags (img, blockquote, p, br, hr, b, i, em, strong, ul, ol, li, div, span, a)
71+
// and safe attributes (src, alt for images; href for links) to prevent XSS and phishing.
7072
if post.HTMLContent != "" {
7173
b.WriteString(sanitizeHTML(post.HTMLContent))
7274
} else {
@@ -179,6 +181,7 @@ func sanitizeHTML(html string) string {
179181
allowedTags := map[string]bool{
180182
"p": true,
181183
"br": true,
184+
"hr": true,
182185
"b": true,
183186
"strong": true,
184187
"i": true,
@@ -221,6 +224,8 @@ func sanitizeHTML(html string) string {
221224
if idx := strings.IndexAny(tagContent, " \t\n"); idx != -1 {
222225
tagName = tagContent[:idx]
223226
}
227+
// Handle self-closing tags like <br/> or <img/>
228+
tagName = strings.TrimSuffix(tagName, "/")
224229
tagName = strings.ToLower(tagName)
225230

226231
if allowedTags[tagName] {

email/templates_test.go

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,36 @@ func TestSanitizeHTMLBlockquotes(t *testing.T) {
126126
}
127127
}
128128

129+
// TestSanitizeHTMLSelfClosingTags tests that self-closing tags like <br/> and <hr/> are preserved.
130+
func TestSanitizeHTMLSelfClosingTags(t *testing.T) {
131+
tests := []struct {
132+
name string
133+
input string
134+
contains string
135+
}{
136+
{"br with slash", "<br/>", "<br>"},
137+
{"br with space and slash", "<br />", "<br>"},
138+
{"br plain", "<br>", "<br>"},
139+
{"hr with slash", "<hr/>", "<hr>"},
140+
{"hr with space and slash", "<hr />", "<hr>"},
141+
{"hr plain", "<hr>", "<hr>"},
142+
{"multiple br tags", "Line 1<br/>Line 2<br />Line 3", "<br>"},
143+
}
144+
145+
for _, tt := range tests {
146+
t.Run(tt.name, func(t *testing.T) {
147+
result := sanitizeHTML(tt.input)
148+
if !strings.Contains(result, tt.contains) {
149+
t.Errorf("Expected %q to contain %q, got: %q", tt.input, tt.contains, result)
150+
}
151+
// Ensure tags are NOT escaped
152+
if strings.Contains(result, "&lt;br") || strings.Contains(result, "&lt;hr") {
153+
t.Errorf("Tags should not be escaped, got: %q", result)
154+
}
155+
})
156+
}
157+
}
158+
129159
// TestSanitizeHTMLLists tests that lists (ul, ol, li) are preserved.
130160
func TestSanitizeHTMLLists(t *testing.T) {
131161
input := `<ul><li>First item</li><li>Second item</li></ul><ol><li>Numbered</li></ol>`
@@ -389,3 +419,89 @@ func TestExtractAttribute(t *testing.T) {
389419
}
390420
}
391421
}
422+
423+
// TestSanitizeHTMLBicyclePost53741781 tests sanitization of real post #53741781 from the Bicycle thread.
424+
// This post contains nested blockquotes, br tags, and links that must be preserved correctly.
425+
// URL: https://advrider.com/f/threads/bicycle-thread.150964/page-5412#post-53741781
426+
func TestSanitizeHTMLBicyclePost53741781(t *testing.T) {
427+
// Real HTML from ADVRider post #53741781 by MN_Smurf
428+
input := `<div class="bbCodeBlock bbCodeQuote" data-author="Mambo Danny">
429+
<aside>
430+
431+
<div class="attribution type">Mambo Danny said:
432+
433+
<a href="goto/post?id=53722273#post-53722273" class="AttributionLink">↑</a>
434+
435+
</div>
436+
437+
<blockquote class="quoteContainer"><div class="quote">I tried pliers and vise-grips on both, to a moderate amount of force, neither moved. I can try two sets of vise-grips next. It was Yoeleo who built the wheels; if it were me I would have used synthetic boat-trailer-bearing grease as the nipple lube. Of course I don&#39;t have any idea if they used any lube at all, nor if my syn grease would have lasted all that time with salt water exposure.<br/>
438+
<br/>
439+
I&#39;m thanking my lucky stars as I had it in the back of my head that I could buy someone&#39;s used tri-bike/time-trial-bike cheap from the mid 20-teens, many of which had aluminum wheels, switch these over to it and have something else to ride. There was a chance I would have got out there and started moving at 25 MPH or over when one of the wheels would fail. But the hard use / hard environment is part of why I relegated that carbon fiber Planet X bike to the indoor trainer only. I&#39;ve been asked why I don&#39;t ride it, and it&#39;s because I don&#39;t trust it anymore.<br/>
440+
<br/>
441+
In time I&#39;ll try penetrating oil on each nipple, let that soak a couple days. Maybe ... maybe... some very focused heat source on the nipple only while trying to keep it away from the carbon fiber rim? Not sure if that would be like a jet-lighter, maybe the soldering iron?</div><div class="quoteExpand">Click to expand...</div></blockquote>
442+
</aside>
443+
</div>Hate to say it, but just replace the spokes. Unlike steel, aluminum adds material when it corrodes. Steel spokes into aluminum nipples in a salt air environment has effectively welded that joint together with galvanic corrosion. You&#39;re going to destroy the parts trying to get them apart.`
444+
445+
result := sanitizeHTML(input)
446+
447+
// Test 1: BR tags should be preserved (not escaped)
448+
if !strings.Contains(result, "<br>") {
449+
t.Error("BR tags should be preserved as actual tags, not escaped")
450+
}
451+
if strings.Contains(result, "&lt;br") {
452+
t.Error("BR tags should NOT be HTML-escaped")
453+
}
454+
455+
// Test 2: Blockquotes should be preserved
456+
if !strings.Contains(result, "<blockquote>") {
457+
t.Error("Blockquote tags should be preserved")
458+
}
459+
460+
// Test 3: Links should be preserved with href
461+
if !strings.Contains(result, `<a href="goto/post?id=53722273#post-53722273">`) {
462+
t.Error("Link href attributes should be preserved")
463+
}
464+
if !strings.Contains(result, "↑</a>") {
465+
t.Error("Link content should be preserved")
466+
}
467+
468+
// Test 4: Div tags should be preserved
469+
if !strings.Contains(result, "<div>") {
470+
t.Error("Div tags should be preserved")
471+
}
472+
473+
// Test 5: Text content should be fully preserved
474+
if !strings.Contains(result, "I tried pliers and vise-grips") {
475+
t.Error("Quote text content should be preserved")
476+
}
477+
if !strings.Contains(result, "Hate to say it, but just replace the spokes") {
478+
t.Error("Main post text should be preserved")
479+
}
480+
if !strings.Contains(result, "galvanic corrosion") {
481+
t.Error("Technical terms should be preserved")
482+
}
483+
484+
// Test 6: HTML entities should be preserved (not double-escaped)
485+
if !strings.Contains(result, "don&#39;t") {
486+
t.Error("HTML entities like &#39; should be preserved as-is")
487+
}
488+
if strings.Contains(result, "&amp;#39;") {
489+
t.Error("HTML entities should NOT be double-escaped")
490+
}
491+
492+
// Test 7: Dangerous attributes should be stripped
493+
if strings.Contains(result, `data-author=`) {
494+
t.Error("data-author attribute should be stripped from divs")
495+
}
496+
if strings.Contains(result, `class="bbCodeQuote"`) {
497+
t.Error("class attributes should be stripped")
498+
}
499+
if strings.Contains(result, `class="AttributionLink"`) {
500+
t.Error("class attributes should be stripped from links")
501+
}
502+
503+
// Test 8: Aside tags are not in whitelist and should be escaped
504+
if strings.Contains(result, "<aside>") {
505+
t.Error("Aside tags should be escaped (not in whitelist)")
506+
}
507+
}

0 commit comments

Comments
 (0)