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
+1 -1
View File
@@ -66,7 +66,7 @@ func (s *SMTPHandler) Dispatcher(payload types.MessagePayload, titleTmpl, bodyTm
return nil, xerrors.Errorf("render subject: %w", err)
}
htmlBody := markdown.HTMLFromMarkdown(bodyTmpl)
htmlBody := markdown.HTMLFromMarkdownSafe(bodyTmpl)
plainBody, err := markdown.PlaintextFromMarkdown(bodyTmpl)
if err != nil {
return nil, xerrors.Errorf("render plaintext body: %w", err)
@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{{ .Labels._subject }}</title>
<title>{{ .Labels._subject | html }}</title>
</head>
<body style="margin: 0; padding: 0; font-family: -apple-system, system-ui, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif; color: #020617; background: #f8fafc;">
<div style="max-width: 600px; margin: 20px auto; padding: 60px; border: 1px solid #e2e8f0; border-radius: 8px; background-color: #fff; text-align: left; font-size: 14px; line-height: 1.5;">
@@ -11,16 +11,16 @@
<img src="{{ logo_url | html }}" alt="{{ app_name | html }} Logo" style="height: 40px;" />
</div>
<h1 style="text-align: center; font-size: 24px; font-weight: 400; margin: 8px 0 32px; line-height: 1.5;">
{{ .Labels._subject }}
{{ .Labels._subject | html }}
</h1>
<div style="line-height: 1.5;">
<p>Hi {{ .UserName }},</p>
<p>Hi {{ .UserName | html }},</p>
{{ .Labels._body }}
</div>
<div style="text-align: center; margin-top: 32px;">
{{ range $action := .Actions }}
<a href="{{ $action.URL }}" style="display: inline-block; padding: 13px 24px; background-color: #020617; color: #f8fafc; text-decoration: none; border-radius: 8px; margin: 0 4px;">
{{ $action.Label }}
<a href="{{ $action.URL | html }}" style="display: inline-block; padding: 13px 24px; background-color: #020617; color: #f8fafc; text-decoration: none; border-radius: 8px; margin: 0 4px;">
{{ $action.Label | html }}
</a>
{{ end }}
</div>
@@ -7,6 +7,8 @@ import (
"github.com/stretchr/testify/require"
markdown "github.com/coder/coder/v2/coderd/render"
"github.com/coder/coder/v2/coderd/notifications/render"
"github.com/coder/coder/v2/coderd/notifications/types"
)
@@ -43,6 +45,354 @@ func TestSMTPHTMLTemplateEscapesAppearanceHelpers(t *testing.T) {
require.False(t, strings.Contains(got, logoURL), "raw logo URL must not be rendered")
}
// TestSMTPHTMLTemplateMarkdownInjection is an end-to-end regression test that
// exercises the full notification email rendering pipeline with malicious input.
// It simulates the complete flow: SanitizePayload -> GoTemplate (body) ->
// HTMLFromMarkdownSafe -> GoTemplate (outer HTML template) and asserts that the
// final HTML email output contains no attacker-controlled links, headings, images,
// scripts, or other injected content.
//
// If this test fails, it likely means a change to the rendering pipeline has
// introduced a Markdown or HTML injection vulnerability in notification emails.
func TestSMTPHTMLTemplateMarkdownInjection(t *testing.T) {
t.Parallel()
helpers := map[string]any{
"base_url": func() string { return "https://coder.example.com" },
"current_year": func() string { return "2026" },
"logo_url": func() string { return "https://coder.example.com/logo.png" },
"app_name": func() string { return "Coder" },
}
tests := []struct {
name string
// payload is the unsanitized notification payload (as it would arrive
// from the database before SanitizePayload runs).
payload types.MessagePayload
// bodyTemplate is the notification body template (stored in the DB).
bodyTemplate string
// absentInHTML lists substrings that must NOT appear in the final HTML
// email output.
absentInHTML []string
// presentInHTML lists substrings that MUST appear in the final HTML.
presentInHTML []string
}{
{
name: "markdown link injection via display name",
payload: types.MessagePayload{
NotificationTemplateID: "00000000-0000-0000-0000-000000000000",
UserName: "Eve\n[Re-authenticate](https://evil.example/login)",
Labels: map[string]string{
"created_account_user_name": "Eve\n[Re-authenticate](https://evil.example/login)",
"initiator": "admin",
},
},
bodyTemplate: `Account created for **{{.Labels.created_account_user_name}}** by **{{.Labels.initiator}}**.`,
absentInHTML: []string{
`<a href="https://evil.example`,
`href="https://evil.example`,
},
presentInHTML: []string{
"<strong>admin</strong>",
},
},
{
name: "heading injection via display name",
payload: types.MessagePayload{
NotificationTemplateID: "00000000-0000-0000-0000-000000000000",
UserName: "Eve\n## URGENT SECURITY ALERT",
Labels: map[string]string{
"suspended_account_user_name": "Eve\n## URGENT SECURITY ALERT",
"initiator": "admin",
},
},
bodyTemplate: `Account **{{.Labels.suspended_account_user_name}}** suspended by **{{.Labels.initiator}}**.`,
absentInHTML: []string{
"<h2>",
"<h1>",
"<h3>",
},
presentInHTML: []string{
"## URGENT SECURITY ALERT", // rendered as literal text
},
},
{
name: "image tracking pixel injection",
payload: types.MessagePayload{
NotificationTemplateID: "00000000-0000-0000-0000-000000000000",
UserName: "![](https://evil.example/track.gif)",
Labels: map[string]string{
"name": "![](https://evil.example/track.gif)",
},
},
bodyTemplate: `Workspace **{{.Labels.name}}** deleted.`,
absentInHTML: []string{
`src="https://evil.example`, // no attacker-controlled image source
},
presentInHTML: []string{
"![]", // rendered as literal text
},
},
{
name: "javascript URI via markdown link",
payload: types.MessagePayload{
NotificationTemplateID: "00000000-0000-0000-0000-000000000000",
UserName: "normal",
Labels: map[string]string{
"name": "[xss](javascript:alert(document.cookie))",
},
},
bodyTemplate: `Workspace **{{.Labels.name}}** updated.`,
absentInHTML: []string{
`href="javascript:`, // no javascript link
},
},
{
name: "raw HTML injection in display name",
payload: types.MessagePayload{
NotificationTemplateID: "00000000-0000-0000-0000-000000000000",
UserName: `<script>alert(1)</script>`,
Labels: map[string]string{},
},
bodyTemplate: `Hello.`,
absentInHTML: []string{
"<script>", // raw script tag must not appear
},
},
{
name: "bare URL autolink in label value",
payload: types.MessagePayload{
NotificationTemplateID: "00000000-0000-0000-0000-000000000000",
UserName: "admin",
Labels: map[string]string{
"name": "workspace https://evil.example test",
},
},
bodyTemplate: `Workspace **{{.Labels.name}}** created.`,
absentInHTML: []string{
`<a href="https://evil.example"`,
},
},
{
// Regression: CommonMark angle-bracket autolink via display name.
// Escaping ">" alone did not stop this ("<" was never escaped).
name: "angle-bracket autolink via display name",
payload: types.MessagePayload{
NotificationTemplateID: "00000000-0000-0000-0000-000000000000",
UserName: "<https://evil.example/login>",
Labels: map[string]string{
"created_account_user_name": "<https://evil.example/login>",
"initiator": "admin",
},
},
bodyTemplate: `Account created for **{{.Labels.created_account_user_name}}** by **{{.Labels.initiator}}**.`,
absentInHTML: []string{
`href="https://evil.example`,
},
presentInHTML: []string{
"<strong>admin</strong>",
},
},
{
// Regression: the backslash-tweaked bypass of the angle-bracket
// autolink escape. A user backslash combined with the sanitizer's
// "\>" left a live autolink terminator until "\" was also escaped.
name: "backslash-tweaked autolink bypass via display name",
payload: types.MessagePayload{
NotificationTemplateID: "00000000-0000-0000-0000-000000000000",
UserName: "<https://evil.example/login\\>",
Labels: map[string]string{
"created_account_user_name": "<https://evil.example/login\\>",
"initiator": "admin",
},
},
bodyTemplate: `Account created for **{{.Labels.created_account_user_name}}** by **{{.Labels.initiator}}**.`,
absentInHTML: []string{
`href="https://evil.example`,
},
presentInHTML: []string{
"<strong>admin</strong>",
},
},
{
name: "legitimate template content renders correctly",
payload: types.MessagePayload{
NotificationTemplateID: "00000000-0000-0000-0000-000000000000",
UserName: "John Smith",
Labels: map[string]string{
"workspace": "my-dev-env",
"initiator": "admin",
},
},
bodyTemplate: `Workspace **{{.Labels.workspace}}** was updated by **{{.Labels.initiator}}**.`,
presentInHTML: []string{
"Hi John Smith,",
"<strong>my-dev-env</strong>",
"<strong>admin</strong>",
},
},
{
name: "unicode display name preserved",
payload: types.MessagePayload{
NotificationTemplateID: "00000000-0000-0000-0000-000000000000",
UserName: "Jos\u00e9 Garc\u00eda",
Labels: map[string]string{"name": "workspace-1"},
},
bodyTemplate: `Workspace **{{.Labels.name}}** ready.`,
presentInHTML: []string{
"Jos\u00e9 Garc\u00eda",
"<strong>workspace-1</strong>",
},
},
// Legitimate rendering regression tests below. These verify that
// production notification features continue working after the
// security hardening.
{
name: "template-authored markdown link renders correctly",
payload: types.MessagePayload{
NotificationTemplateID: "00000000-0000-0000-0000-000000000000",
UserName: "user",
Labels: map[string]string{
"name": "my-workspace",
"reason": "inactivity",
},
},
bodyTemplate: `Your workspace **{{.Labels.name}}** has been marked as [**dormant**](https://coder.com/docs/templates/schedule#dormancy-threshold-enterprise) because of {{.Labels.reason}}.`,
presentInHTML: []string{
`<a href="https://coder.com/docs/templates/schedule#dormancy-threshold-enterprise">`,
"<strong>dormant</strong>",
"<strong>my-workspace</strong>",
},
},
{
name: "markdown list renders correctly",
payload: types.MessagePayload{
NotificationTemplateID: "00000000-0000-0000-0000-000000000000",
UserName: "admin",
Labels: map[string]string{},
},
bodyTemplate: "Build failures:\n\n- **template-v1** failed 3 times\n- **template-v2** failed 1 time",
presentInHTML: []string{
"<li>",
"<strong>template-v1</strong>",
"<strong>template-v2</strong>",
},
},
{
name: "emphasis and code spans render correctly",
payload: types.MessagePayload{
NotificationTemplateID: "00000000-0000-0000-0000-000000000000",
UserName: "admin",
Labels: map[string]string{
"workspace": "my-ws",
},
},
bodyTemplate: "Volume **`/home/coder`** is over 90%% full in workspace **{{.Labels.workspace}}**.",
presentInHTML: []string{
"<code>/home/coder</code>",
"<strong>my-ws</strong>",
},
},
{
name: "action buttons render with proper URLs",
payload: types.MessagePayload{
NotificationTemplateID: "00000000-0000-0000-0000-000000000000",
UserName: "user",
Labels: map[string]string{},
Actions: []types.TemplateAction{
{Label: "View workspace", URL: "https://coder.example.com/@user/my-workspace"},
{Label: "View template", URL: "https://coder.example.com/templates/docker"},
},
},
bodyTemplate: `Your workspace is ready.`,
presentInHTML: []string{
`href="https://coder.example.com/@user/my-workspace"`,
"View workspace",
`href="https://coder.example.com/templates/docker"`,
"View template",
},
},
{
name: "footer links and logo render correctly",
payload: types.MessagePayload{
NotificationTemplateID: "00000000-0000-0000-0000-000000000000",
UserName: "user",
Labels: map[string]string{},
},
bodyTemplate: `Hello.`,
presentInHTML: []string{
// Footer links
`href="https://coder.example.com/settings/notifications"`,
"manage your notification settings",
"Stop receiving emails like this",
// Logo
`<img src="https://coder.example.com/logo.png"`,
// Copyright
"Coder. All rights reserved",
},
},
{
name: "conditional label rendering",
payload: types.MessagePayload{
NotificationTemplateID: "00000000-0000-0000-0000-000000000000",
UserName: "admin",
Labels: map[string]string{
"created_account_name": "newuser",
"created_account_user_name": "New User",
"initiator": "admin",
},
},
bodyTemplate: `New user account **{{.Labels.created_account_name}}** has been created.` + "\n\n" +
`This new user account was created {{if .Labels.created_account_user_name}}for **{{.Labels.created_account_user_name}}** {{end}}by **{{.Labels.initiator}}**.`,
presentInHTML: []string{
"<strong>newuser</strong>",
"<strong>New User</strong>",
"<strong>admin</strong>",
"for",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
// Step 1: Render the body from a sanitized copy, leaving the
// payload itself raw (as notifier.prepare does).
sanitized := render.SanitizedPayload(tt.payload)
// Step 2: Render body template via GoTemplate
body, err := render.GoTemplate(tt.bodyTemplate, sanitized, nil)
require.NoError(t, err)
// Step 3: Convert markdown body to HTML (as SMTP dispatcher does)
htmlBody := markdown.HTMLFromMarkdownSafe(body)
// Step 4: Render subject via PlaintextFromMarkdown
subject := "Test notification"
// Step 5: Inject into outer HTML template using the raw payload (as
// the SMTP dispatcher does). The greeting interpolates the raw
// UserName, made safe by the template's `| html`, not by Markdown
// escaping.
tt.payload.Labels["_subject"] = subject
tt.payload.Labels["_body"] = htmlBody
finalHTML, err := render.GoTemplate(htmlTemplate, tt.payload, helpers)
require.NoError(t, err)
// Step 6: Assert on final HTML output
for _, s := range tt.absentInHTML {
require.False(t, strings.Contains(finalHTML, s),
"final HTML email must NOT contain %q", s)
}
for _, s := range tt.presentInHTML {
require.True(t, strings.Contains(finalHTML, s),
"final HTML email must contain %q", s)
}
})
}
}
func TestValidateFromAddr(t *testing.T) {
t.Parallel()