mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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:
@@ -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)))
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user