fix: markdown rendering improvements

Improvements to markdown rendering in notification emails:

- More consistent escaping of values interpolated into notification templates
- Stricter link handling in the notification email renderer, scoped to the notification rendering path
- HTML escaping of values interpolated into the outer email template

- Expanded unit and end-to-end coverage of the notification rendering pipeline
- `make gen` run to regenerate golden files for SMTP and webhook notification templates
This commit is contained in:
Jakub Domeracki
2026-08-10 10:45:19 +02:00
parent 192842c8f2
commit 07f79af65b
37 changed files with 962 additions and 70 deletions
+21 -2
View File
@@ -114,10 +114,29 @@ func PlaintextFromMarkdown(markdown string) (string, error) {
}
func HTMLFromMarkdown(markdown string) string {
p := parser.NewWithExtensions(parser.CommonExtensions | parser.HardLineBreak) // Added HardLineBreak.
return renderHTMLFromMarkdown(markdown, parser.CommonExtensions|parser.HardLineBreak, html.CommonFlags|html.SkipHTML)
}
// HTMLFromMarkdownSafe renders Markdown to HTML with additional security
// hardening for content that may include user-controlled values (e.g.
// notification emails): autolinks are disabled so that only explicit Markdown
// link syntax produces <a> tags, and Safelink drops links whose scheme is not
// http/https/ftp/mailto.
//
// The hardening is scoped to this function. HTMLFromMarkdown renders
// admin-authored deployment text (OIDCConfig.SignupsDisabledText) and keeps the
// standard flags so links with custom schemes (e.g. slack://) still render.
func HTMLFromMarkdownSafe(markdown string) string {
extensions := parser.CommonExtensions | parser.HardLineBreak
extensions &^= parser.Autolink
return renderHTMLFromMarkdown(markdown, extensions, html.CommonFlags|html.SkipHTML|html.Safelink)
}
func renderHTMLFromMarkdown(markdown string, extensions parser.Extensions, flags html.Flags) string {
p := parser.NewWithExtensions(extensions)
doc := p.Parse([]byte(markdown))
renderer := html.NewRenderer(html.RendererOptions{
Flags: html.CommonFlags | html.SkipHTML,
Flags: flags,
})
return string(bytes.TrimSpace(gomarkdown.Render(doc, renderer)))
}
+112
View File
@@ -1,6 +1,7 @@
package render_test
import (
"strings"
"testing"
"github.com/stretchr/testify/require"
@@ -143,3 +144,114 @@ func TestInnerTextFromMarkdown(t *testing.T) {
})
}
}
func TestHTMLFromMarkdownSafe(t *testing.T) {
t.Parallel()
tests := []struct {
name string
input string
contains []string
absent []string
}{
{
name: "explicit https link preserved",
input: "[Coder](https://coder.com)",
contains: []string{`<a href="https://coder.com">Coder</a>`},
},
{
name: "explicit http link preserved",
input: "[Link](http://example.com)",
contains: []string{`<a href="http://example.com">Link</a>`},
},
{
name: "javascript URI blocked by Safelink",
input: "[Click](javascript:alert(1))",
absent: []string{"javascript:", "<a href"},
},
{
name: "data URI blocked by Safelink",
input: "[Click](data:text/html,<script>alert(1)</script>)",
absent: []string{"data:", "<a href"},
},
{
name: "bare URL NOT auto-linked",
input: "Visit https://evil.example for details",
absent: []string{"<a href"},
contains: []string{"https://evil.example"},
},
{
name: "bold and emphasis still work",
input: "**bold** and *italic*",
contains: []string{"<strong>bold</strong>", "<em>italic</em>"},
},
{
name: "raw HTML stripped",
input: `<script>alert(1)</script>`,
absent: []string{"<script>"},
},
{
name: "escaped markdown renders as literal text",
input: `\[not a link\]\(https://evil.example\)`,
absent: []string{"<a href"},
contains: []string{"[not a link]"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
result := render.HTMLFromMarkdownSafe(tt.input)
for _, s := range tt.contains {
if !strings.Contains(result, s) {
t.Errorf("output %q should contain %q", result, s)
}
}
for _, s := range tt.absent {
if strings.Contains(result, s) {
t.Errorf("output %q should NOT contain %q", result, s)
}
}
})
}
}
func TestHTMLFromMarkdownSafelink(t *testing.T) {
t.Parallel()
// Safelink is scoped to HTMLFromMarkdownSafe. HTMLFromMarkdown renders
// admin-authored deployment text and keeps its pre-existing behavior, so
// it does not block or rewrite link schemes.
t.Run("HTMLFromMarkdownSafe blocks javascript URI", func(t *testing.T) {
t.Parallel()
result := render.HTMLFromMarkdownSafe("[Click](javascript:alert(1))")
if strings.Contains(result, "javascript:") {
t.Error("HTMLFromMarkdownSafe should block javascript: URIs via Safelink")
}
})
t.Run("HTMLFromMarkdown does not rewrite custom-scheme links", func(t *testing.T) {
t.Parallel()
// Admin-authored deployment text may legitimately use custom schemes.
result := render.HTMLFromMarkdown("[Open the app](slack://channel)")
if !strings.Contains(result, `href="slack://channel"`) {
t.Errorf("HTMLFromMarkdown should render custom-scheme links, got %q", result)
}
})
t.Run("HTMLFromMarkdown allows autolinks", func(t *testing.T) {
t.Parallel()
result := render.HTMLFromMarkdown("Visit https://coder.com for details")
if !strings.Contains(result, "<a href") {
t.Error("HTMLFromMarkdown should auto-link bare URLs")
}
})
t.Run("HTMLFromMarkdownSafe disables autolinks", func(t *testing.T) {
t.Parallel()
result := render.HTMLFromMarkdownSafe("Visit https://coder.com for details")
if strings.Contains(result, "<a href") {
t.Error("HTMLFromMarkdownSafe should NOT auto-link bare URLs")
}
})
}