fix(coderd/notifications): HTML-escape the email template values by BobbyHo · Pull Request #28397 · coder/coder · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
9594a14
fix(coderd): prevent markdown injection in notifications
BobbyHo Aug 20, 2026
7844bce
fix(coderd/render): escape leading emphasis and list markers
BobbyHo Aug 20, 2026
6a9c44c
test(coderd/render): fail on structure cases that assert nothing
BobbyHo Aug 20, 2026
f319632
Merge branch 'main' into coder-plat-273-sec-93
BobbyHo Aug 24, 2026
f49399b
Merge branch 'main' into coder-plat-273-sec-93
BobbyHo Aug 24, 2026
9802c7a
Merge branch 'main' into coder-plat-273-sec-93
BobbyHo Aug 24, 2026
cf118ad
chore(scripts): exclude notification goldens from the emdash check
BobbyHo Aug 24, 2026
5a45371
fix(coderd): close markdown escaping gaps in notifications
BobbyHo Aug 24, 2026
6867339
docs(coderd/notifications): correct the escaping guarantee in notifie…
BobbyHo Aug 24, 2026
b42dcf7
fix(coderd/render): neutralize a fold construct on a value's first line
BobbyHo Aug 24, 2026
a671d84
docs(coderd): trim notification escaping comments to the non-obvious
BobbyHo Aug 24, 2026
a4df52f
docs(coderd/render): record the destinations Safelink drops
BobbyHo Aug 24, 2026
d391ac0
fix(coderd): fold the Subject header on its encoded length
BobbyHo Aug 24, 2026
479a032
fix(coderd/notifications): HTML-escape the email template sinks
BobbyHo Aug 20, 2026
209321d
docs(coderd/notifications): correct the escaping note now the sinks e…
BobbyHo Aug 24, 2026
83fb5a2
Merge branch 'main' into coder-plat-273-sec-93-html-sinks
BobbyHo Aug 25, 2026
1b52905
Merge branch 'main' into coder-plat-273-sec-93-html-sinks
BobbyHo Aug 25, 2026
3af793c
docs(coderd/notifications): trim the comments on the HTML escaping test
BobbyHo Aug 25, 2026
f5b526d
test(coderd/notifications/dispatch): guard the action value escaping
BobbyHo Aug 25, 2026
029e14f
fix(coderd/notifications): escape the remaining template values
BobbyHo Aug 25, 2026
e930930
Merge branch 'main' into coder-plat-273-sec-93-html-sinks
BobbyHo Aug 25, 2026
83bb050
Merge branch 'main' into coder-plat-273-sec-93-html-sinks
BobbyHo Aug 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 8 additions & 8 deletions coderd/notifications/dispatch/smtp/html.gotmpl
160 changes: 154 additions & 6 deletions coderd/notifications/dispatch/smtp_internal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,100 @@ import (

"github.com/coder/coder/v2/coderd/notifications/render"
"github.com/coder/coder/v2/coderd/notifications/types"
markdown "github.com/coder/coder/v2/coderd/render"
)

// Benign values, so a test measures only what its own payload injected.
func templateHelpers() map[string]any {
return map[string]any{
"base_url": func() string { return "https://coder.example.com" },
Comment thread
BobbyHo marked this conversation as resolved.
"current_year": func() string { return "2026" },
"logo_url": func() string { return "https://coder.example.com/logo.png" },
"app_name": func() string { return "Coder" },
}
}

func TestSMTPHTMLTemplateEscapesUntrustedValues(t *testing.T) {
t.Parallel()

for _, tc := range []struct {
name string
title string
userName string
actions []types.TemplateAction
injected string
}{
{
name: "EntityEncodedAnchorInSubject",
title: `Template "<a href="https://attacker.example/login">Re-authenticate now</a>" deleted`,
userName: "Bobby",
injected: `<a href="https://attacker.example/login">Re-authenticate now</a>`,
},
{
name: "EntityEncodedImageInSubject",
title: `Workspace "&lt;img src=x onerror="alert(1)"&gt;" marked dormant`,
userName: "Bobby",
injected: `<img src=x onerror="alert(1)">`,
},
{
name: "RawHTMLInUserName",
title: "Account suspended",
userName: `Bobby <img src=x onerror="alert(1)">`,
injected: `<img src=x onerror="alert(1)">`,
},
{
name: "RawHTMLInActionLabel",
title: "Account suspended",
userName: "Bobby",
actions: []types.TemplateAction{
{Label: `<img src=x onerror="alert(1)">`, URL: "https://coder.example.com/"},
},
injected: `<img src=x onerror="alert(1)">`,
},
{
name: "RawHTMLInActionURL",
title: "Account suspended",
userName: "Bobby",
actions: []types.TemplateAction{
{Label: "Open Coder", URL: `https://coder.example.com/?x=<script>alert(1)</script>`},
},
injected: `<script>alert(1)</script>`,
},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()

// Decodes the entities, so the title arrives as live markup.
subject, err := markdown.PlaintextFromMarkdown(tc.title)
require.NoError(t, err)

// Actions are set as the template sees them. The enqueuer renders
// them into JSON first, which rejects a `"` of its own accord.
payload := types.MessagePayload{
NotificationTemplateID: "00000000-0000-0000-0000-000000000000",
UserName: tc.userName,
Actions: tc.actions,
Labels: map[string]string{
"_subject": subject,
"_body": "<p>Test body</p>",
},
}

got, err := render.GoTemplate(htmlTemplate, payload, templateHelpers())
require.NoError(t, err)

escaped := html.EscapeString(tc.injected)
require.NotEqual(t, tc.injected, escaped,
"case carries no HTML to escape, so it guards nothing")

require.NotContains(t, got, tc.injected,
"untrusted markup reached the rendered email: %s", got)
require.Contains(t, got, escaped,
"the value must still be displayed, entity encoded: %s", got)
})
}
}

func TestSMTPHTMLTemplateEscapesAppearanceHelpers(t *testing.T) {
t.Parallel()

Expand All @@ -28,12 +120,9 @@ func TestSMTPHTMLTemplateEscapesAppearanceHelpers(t *testing.T) {
"_body": "<p>Test body</p>",
},
}
helpers := map[string]any{
"base_url": func() string { return "https://coder.example.com" },
"current_year": func() string { return "2026" },
"logo_url": func() string { return logoURL },
"app_name": func() string { return appName },
}
helpers := templateHelpers()
helpers["logo_url"] = func() string { return logoURL }
helpers["app_name"] = func() string { return appName }

got, err := render.GoTemplate(htmlTemplate, payload, helpers)
require.NoError(t, err)
Expand All @@ -44,6 +133,65 @@ func TestSMTPHTMLTemplateEscapesAppearanceHelpers(t *testing.T) {
require.False(t, strings.Contains(got, logoURL), "raw logo URL must not be rendered")
}

// The template escapes every value it interpolates except _body, which is
// trusted rendered Markdown. The three values here cannot carry markup in
// production, so this test is the only thing that fails if their escaping is
// removed.
func TestSMTPHTMLTemplateEscapesTrustedValues(t *testing.T) {
t.Parallel()

const injected = `a"onclick=alert(1)`

for _, tc := range []struct {
name string
apply func(*types.MessagePayload, map[string]any)
}{
{
// net/url preserves a quote in the query and --access-url is
// validated for its scheme only, so an operator can land this.
name: "BaseURL",
apply: func(_ *types.MessagePayload, h map[string]any) {
h["base_url"] = func() string { return "https://coder.example.com/?q=" + injected }
},
},
{
name: "CurrentYear",
apply: func(_ *types.MessagePayload, h map[string]any) {
h["current_year"] = func() string { return injected }
},
},
{
name: "NotificationTemplateID",
apply: func(p *types.MessagePayload, _ map[string]any) {
p.NotificationTemplateID = injected
},
},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()

payload := types.MessagePayload{
NotificationTemplateID: "00000000-0000-0000-0000-000000000000",
UserName: "Test User",
Labels: map[string]string{
"_subject": "Test notification",
"_body": "<p>Test body</p>",
},
}
helpers := templateHelpers()
tc.apply(&payload, helpers)

got, err := render.GoTemplate(htmlTemplate, payload, helpers)
require.NoError(t, err)

require.NotContains(t, got, injected,
"raw value reached the rendered email: %s", got)
require.Contains(t, got, html.EscapeString(injected),
"the value must still be displayed, entity encoded: %s", got)
})
}
}

func TestValidateFromAddr(t *testing.T) {
t.Parallel()

Expand Down
4 changes: 3 additions & 1 deletion coderd/notifications/notifier.go
Original file line number Diff line number Diff line change
Expand Up @@ -253,7 +253,9 @@ func (n *notifier) prepare(ctx context.Context, msg database.AcquireNotification
// Label and data values are user-controlled while the templates around them
// are not, so Markdown structure in a value is neutralized before it reaches
// the template. The dispatcher still receives the unescaped payload, because
// the webhook contract surfaces enqueued values verbatim.
// the webhook contract surfaces enqueued values verbatim. smtp/html.gotmpl
// escapes at its own sinks, which it must: PlaintextFromMarkdown strips this
// escaping back out of _subject.
escaped := payload.EscapedForMarkdown()

var title, body string
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ Content-Type: text/html; charset=UTF-8
<meta charset=3D"UTF-8" />
<meta name=3D"viewport" content=3D"width=3Ddevice-width, initial-scale=
=3D1.0" />
<title>You've reached your monthly AI budget limit</title>
<title>You&#39;ve reached your monthly AI budget limit</title>
</head>
<body style=3D"margin: 0; padding: 0; font-family: -apple-system, system-=
ui, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarel=
Expand All @@ -45,7 +45,7 @@ er Logo" style=3D"height: 40px;" />
</div>
<h1 style=3D"text-align: center; font-size: 24px; font-weight: 400; m=
argin: 8px 0 32px; line-height: 1.5;">
You've reached your monthly AI budget limit
You&#39;ve reached your monthly AI budget limit
</h1>
<div style=3D"line-height: 1.5;">
<p>Hi Bobby,</p>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ Content-Type: text/html; charset=UTF-8
<meta charset=3D"UTF-8" />
<meta name=3D"viewport" content=3D"width=3Ddevice-width, initial-scale=
=3D1.0" />
<title>You're approaching your monthly AI budget limit</title>
<title>You&#39;re approaching your monthly AI budget limit</title>
</head>
<body style=3D"margin: 0; padding: 0; font-family: -apple-system, system-=
ui, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarel=
Expand All @@ -44,7 +44,7 @@ er Logo" style=3D"height: 40px;" />
</div>
<h1 style=3D"text-align: center; font-size: 24px; font-weight: 400; m=
argin: 8px 0 32px; line-height: 1.5;">
You're approaching your monthly AI budget limit
You&#39;re approaching your monthly AI budget limit
</h1>
<div style=3D"line-height: 1.5;">
<p>Hi Bobby,</p>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ Content-Type: text/html; charset=UTF-8
<meta charset=3D"UTF-8" />
<meta name=3D"viewport" content=3D"width=3Ddevice-width, initial-scale=
=3D1.0" />
<title>Task 'my-workspace' completed</title>
<title>Task &#39;my-workspace&#39; completed</title>
</head>
<body style=3D"margin: 0; padding: 0; font-family: -apple-system, system-=
ui, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarel=
Expand All @@ -44,7 +44,7 @@ er Logo" style=3D"height: 40px;" />
</div>
<h1 style=3D"text-align: center; font-size: 24px; font-weight: 400; m=
argin: 8px 0 32px; line-height: 1.5;">
Task 'my-workspace' completed
Task &#39;my-workspace&#39; completed
</h1>
<div style=3D"line-height: 1.5;">
<p>Hi Bobby,</p>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ Content-Type: text/html; charset=UTF-8
<meta charset=3D"UTF-8" />
<meta name=3D"viewport" content=3D"width=3Ddevice-width, initial-scale=
=3D1.0" />
<title>Task 'my-workspace' failed</title>
<title>Task &#39;my-workspace&#39; failed</title>
</head>
<body style=3D"margin: 0; padding: 0; font-family: -apple-system, system-=
ui, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarel=
Expand All @@ -44,7 +44,7 @@ er Logo" style=3D"height: 40px;" />
</div>
<h1 style=3D"text-align: center; font-size: 24px; font-weight: 400; m=
argin: 8px 0 32px; line-height: 1.5;">
Task 'my-workspace' failed
Task &#39;my-workspace&#39; failed
</h1>
<div style=3D"line-height: 1.5;">
<p>Hi Bobby,</p>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ Content-Type: text/html; charset=UTF-8
<meta charset=3D"UTF-8" />
<meta name=3D"viewport" content=3D"width=3Ddevice-width, initial-scale=
=3D1.0" />
<title>Task 'my-workspace' is idle</title>
<title>Task &#39;my-workspace&#39; is idle</title>
</head>
<body style=3D"margin: 0; padding: 0; font-family: -apple-system, system-=
ui, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarel=
Expand All @@ -44,7 +44,7 @@ er Logo" style=3D"height: 40px;" />
</div>
<h1 style=3D"text-align: center; font-size: 24px; font-weight: 400; m=
argin: 8px 0 32px; line-height: 1.5;">
Task 'my-workspace' is idle
Task &#39;my-workspace&#39; is idle
</h1>
<div style=3D"line-height: 1.5;">
<p>Hi Bobby,</p>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ Content-Type: text/html; charset=UTF-8
<meta charset=3D"UTF-8" />
<meta name=3D"viewport" content=3D"width=3Ddevice-width, initial-scale=
=3D1.0" />
<title>Task 'my-task' is paused</title>
<title>Task &#39;my-task&#39; is paused</title>
</head>
<body style=3D"margin: 0; padding: 0; font-family: -apple-system, system-=
ui, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarel=
Expand All @@ -44,7 +44,7 @@ er Logo" style=3D"height: 40px;" />
</div>
<h1 style=3D"text-align: center; font-size: 24px; font-weight: 400; m=
argin: 8px 0 32px; line-height: 1.5;">
Task 'my-task' is paused
Task &#39;my-task&#39; is paused
</h1>
<div style=3D"line-height: 1.5;">
<p>Hi Bobby,</p>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ Content-Type: text/html; charset=UTF-8
<meta charset=3D"UTF-8" />
<meta name=3D"viewport" content=3D"width=3Ddevice-width, initial-scale=
=3D1.0" />
<title>Task 'my-task' has resumed</title>
<title>Task &#39;my-task&#39; has resumed</title>
</head>
<body style=3D"margin: 0; padding: 0; font-family: -apple-system, system-=
ui, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarel=
Expand All @@ -44,7 +44,7 @@ er Logo" style=3D"height: 40px;" />
</div>
<h1 style=3D"text-align: center; font-size: 24px; font-weight: 400; m=
argin: 8px 0 32px; line-height: 1.5;">
Task 'my-task' has resumed
Task &#39;my-task&#39; has resumed
</h1>
<div style=3D"line-height: 1.5;">
<p>Hi Bobby,</p>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ Content-Type: text/html; charset=UTF-8
<meta charset=3D"UTF-8" />
<meta name=3D"viewport" content=3D"width=3Ddevice-width, initial-scale=
=3D1.0" />
<title>Task 'my-workspace' is working</title>
<title>Task &#39;my-workspace&#39; is working</title>
</head>
<body style=3D"margin: 0; padding: 0; font-family: -apple-system, system-=
ui, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarel=
Expand All @@ -44,7 +44,7 @@ er Logo" style=3D"height: 40px;" />
</div>
<h1 style=3D"text-align: center; font-size: 24px; font-weight: 400; m=
argin: 8px 0 32px; line-height: 1.5;">
Task 'my-workspace' is working
Task &#39;my-workspace&#39; is working
</h1>
<div style=3D"line-height: 1.5;">
<p>Hi Bobby,</p>
Expand Down
Loading
Loading