Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit fcacdea

Browse files
authored
fix(coderd/notifications): HTML-escape the email template values (#28397) (#28644)
Backport of #28397 Original PR: #28397 — fix(coderd/notifications): HTML-escape the email template values Merge commit: 2236710 Requested by: @BobbyHo Opened manually because the Backport workflow run for `release/2.35` failed: https://github.com/coder/coder/actions/runs/32985803559/job/98231735668 ## Why the automatic backport failed Two separate things went wrong in that run, and only one of them is about conflicts. The cherry-pick hit seven `modify/delete` conflicts (below). That alone is not fatal — the same happened for `release/2.36` and `release/2.37`, where the job still pushed a placeholder branch and opened a PR for manual resolution. What actually failed the job was the push: ``` ! [remote rejected] backport/28397-to-2.35 -> backport/28397-to-2.35 (Unable to determine if workflow can be created or updated due to timeout; `workflows` scope may be required.) ``` This cherry-pick touches no files under `.github/workflows/`, so this is GitHub timing out while determining scope rather than a genuine permission gap. The push was retried by hand for this PR and succeeded unchanged. The net effect of the failure was that no branch and no PR were created for 2.35 at all, so this one is opened from scratch rather than fixed up in place. ## Manual resolution: 7 golden files dropped `release/2.35` already carries #28611 (the 2.35 backport of #28340), so the `notifier.go` hunk applies cleanly. What remains is a genuine `modify/delete` conflict on seven golden files: ``` TemplateAIBudgetLimitReachedUser.html.golden TemplateAIBudgetWarningUser.html.golden TemplateUserAccountActivatedServiceAccount.html.golden TemplateUserAccountCreatedServiceAccount.html.golden TemplateUserAccountCreatedWithoutAccountType.html.golden TemplateUserAccountDeletedServiceAccount.html.golden TemplateUserAccountSuspendedServiceAccount.html.golden ``` These are fixtures for the AI budget and service-account notification templates, both of which postdate the 2.35 branch point. `coderd/notifications/` on `release/2.35` contains no reference to `AIBudget`, `BudgetLimitReached`, `BudgetWarning`, `ServiceAccount` or `AccountType`, so there is no template to render them and no test case that reads them. **All seven were removed rather than added**; carrying them over would leave orphan fixtures. This is the only deviation from the original PR. Verified with a diff-of-diffs: excluding those seven paths, this commit is byte-identical to #28397. The escaping changes to `html.gotmpl`, `notifier.go` and `smtp_internal_test.go` are fully intact. Net: 32 files, +230/−75 (upstream: 39 files, +244/−89 — the delta is exactly the seven goldens). The smtp golden directory holds 38 files before and after, so nothing was added or lost. ## Verification - `go vet ./coderd/notifications/...` — clean - `go test ./coderd/notifications/dispatch/...` — pass, including the three new `TestSMTPHTMLTemplateEscapes*` tests - `go test ./coderd/notifications/ -run TestNotificationTemplates_Golden` — pass across all affected goldens ## Related backports - #28603 — `release/2.37` - #28604 — `release/2.36` - #28643 — `release/2.29` (still needs manual resolution)
1 parent 90970fd commit fcacdea

32 files changed

Lines changed: 230 additions & 75 deletions

coderd/notifications/dispatch/smtp/html.gotmpl

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,31 +3,31 @@
33
<head>
44
<meta charset="UTF-8" />
55
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
6-
<title>{{ .Labels._subject }}</title>
6+
<title>{{ .Labels._subject | html }}</title>
77
</head>
88
<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;">
99
<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;">
1010
<div style="text-align: center;">
1111
<img src="{{ logo_url | html }}" alt="{{ app_name | html }} Logo" style="height: 40px;" />
1212
</div>
1313
<h1 style="text-align: center; font-size: 24px; font-weight: 400; margin: 8px 0 32px; line-height: 1.5;">
14-
{{ .Labels._subject }}
14+
{{ .Labels._subject | html }}
1515
</h1>
1616
<div style="line-height: 1.5;">
17-
<p>Hi {{ .UserName }},</p>
17+
<p>Hi {{ .UserName | html }},</p>
1818
{{ .Labels._body }}
1919
</div>
2020
<div style="text-align: center; margin-top: 32px;">
2121
{{ range $action := .Actions }}
22-
<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;">
23-
{{ $action.Label }}
22+
<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;">
23+
{{ $action.Label | html }}
2424
</a>
2525
{{ end }}
2626
</div>
2727
<div style="border-top: 1px solid #e2e8f0; color: #475569; font-size: 12px; margin-top: 64px; padding-top: 24px; line-height: 1.6;">
28-
<p>&copy;&nbsp;{{ current_year }}&nbsp;Coder. All rights reserved&nbsp;-&nbsp;<a href="{{ base_url }}" style="color: #2563eb; text-decoration: none;">{{ base_url }}</a></p>
29-
<p><a href="{{ base_url }}/settings/notifications" style="color: #2563eb; text-decoration: none;">Click here to manage your notification settings</a></p>
30-
<p><a href="{{ base_url }}/settings/notifications?disabled={{ .NotificationTemplateID }}" style="color: #2563eb; text-decoration: none;">Stop receiving emails like this</a></p>
28+
<p>&copy;&nbsp;{{ current_year | html }}&nbsp;Coder. All rights reserved&nbsp;-&nbsp;<a href="{{ base_url | html }}" style="color: #2563eb; text-decoration: none;">{{ base_url | html }}</a></p>
29+
<p><a href="{{ base_url | html }}/settings/notifications" style="color: #2563eb; text-decoration: none;">Click here to manage your notification settings</a></p>
30+
<p><a href="{{ base_url | html }}/settings/notifications?disabled={{ .NotificationTemplateID | html }}" style="color: #2563eb; text-decoration: none;">Stop receiving emails like this</a></p>
3131
</div>
3232
</div>
3333
</body>

coderd/notifications/dispatch/smtp_internal_test.go

Lines changed: 154 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,100 @@ import (
1010

1111
"github.com/coder/coder/v2/coderd/notifications/render"
1212
"github.com/coder/coder/v2/coderd/notifications/types"
13+
markdown "github.com/coder/coder/v2/coderd/render"
1314
)
1415

16+
// Benign values, so a test measures only what its own payload injected.
17+
func templateHelpers() map[string]any {
18+
return map[string]any{
19+
"base_url": func() string { return "https://coder.example.com" },
20+
"current_year": func() string { return "2026" },
21+
"logo_url": func() string { return "https://coder.example.com/logo.png" },
22+
"app_name": func() string { return "Coder" },
23+
}
24+
}
25+
26+
func TestSMTPHTMLTemplateEscapesUntrustedValues(t *testing.T) {
27+
t.Parallel()
28+
29+
for _, tc := range []struct {
30+
name string
31+
title string
32+
userName string
33+
actions []types.TemplateAction
34+
injected string
35+
}{
36+
{
37+
name: "EntityEncodedAnchorInSubject",
38+
title: `Template "&lt;a href="https://attacker.example/login"&gt;Re-authenticate now&lt;/a&gt;" deleted`,
39+
userName: "Bobby",
40+
injected: `<a href="https://attacker.example/login">Re-authenticate now</a>`,
41+
},
42+
{
43+
name: "EntityEncodedImageInSubject",
44+
title: `Workspace "&lt;img src=x onerror="alert(1)"&gt;" marked dormant`,
45+
userName: "Bobby",
46+
injected: `<img src=x onerror="alert(1)">`,
47+
},
48+
{
49+
name: "RawHTMLInUserName",
50+
title: "Account suspended",
51+
userName: `Bobby <img src=x onerror="alert(1)">`,
52+
injected: `<img src=x onerror="alert(1)">`,
53+
},
54+
{
55+
name: "RawHTMLInActionLabel",
56+
title: "Account suspended",
57+
userName: "Bobby",
58+
actions: []types.TemplateAction{
59+
{Label: `<img src=x onerror="alert(1)">`, URL: "https://coder.example.com/"},
60+
},
61+
injected: `<img src=x onerror="alert(1)">`,
62+
},
63+
{
64+
name: "RawHTMLInActionURL",
65+
title: "Account suspended",
66+
userName: "Bobby",
67+
actions: []types.TemplateAction{
68+
{Label: "Open Coder", URL: `https://coder.example.com/?x=<script>alert(1)</script>`},
69+
},
70+
injected: `<script>alert(1)</script>`,
71+
},
72+
} {
73+
t.Run(tc.name, func(t *testing.T) {
74+
t.Parallel()
75+
76+
// Decodes the entities, so the title arrives as live markup.
77+
subject, err := markdown.PlaintextFromMarkdown(tc.title)
78+
require.NoError(t, err)
79+
80+
// Actions are set as the template sees them. The enqueuer renders
81+
// them into JSON first, which rejects a `"` of its own accord.
82+
payload := types.MessagePayload{
83+
NotificationTemplateID: "00000000-0000-0000-0000-000000000000",
84+
UserName: tc.userName,
85+
Actions: tc.actions,
86+
Labels: map[string]string{
87+
"_subject": subject,
88+
"_body": "<p>Test body</p>",
89+
},
90+
}
91+
92+
got, err := render.GoTemplate(htmlTemplate, payload, templateHelpers())
93+
require.NoError(t, err)
94+
95+
escaped := html.EscapeString(tc.injected)
96+
require.NotEqual(t, tc.injected, escaped,
97+
"case carries no HTML to escape, so it guards nothing")
98+
99+
require.NotContains(t, got, tc.injected,
100+
"untrusted markup reached the rendered email: %s", got)
101+
require.Contains(t, got, escaped,
102+
"the value must still be displayed, entity encoded: %s", got)
103+
})
104+
}
105+
}
106+
15107
func TestSMTPHTMLTemplateEscapesAppearanceHelpers(t *testing.T) {
16108
t.Parallel()
17109

@@ -28,12 +120,9 @@ func TestSMTPHTMLTemplateEscapesAppearanceHelpers(t *testing.T) {
28120
"_body": "<p>Test body</p>",
29121
},
30122
}
31-
helpers := map[string]any{
32-
"base_url": func() string { return "https://coder.example.com" },
33-
"current_year": func() string { return "2026" },
34-
"logo_url": func() string { return logoURL },
35-
"app_name": func() string { return appName },
36-
}
123+
helpers := templateHelpers()
124+
helpers["logo_url"] = func() string { return logoURL }
125+
helpers["app_name"] = func() string { return appName }
37126

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

136+
// The template escapes every value it interpolates except _body, which is
137+
// trusted rendered Markdown. The three values here cannot carry markup in
138+
// production, so this test is the only thing that fails if their escaping is
139+
// removed.
140+
func TestSMTPHTMLTemplateEscapesTrustedValues(t *testing.T) {
141+
t.Parallel()
142+
143+
const injected = `a"onclick=alert(1)`
144+
145+
for _, tc := range []struct {
146+
name string
147+
apply func(*types.MessagePayload, map[string]any)
148+
}{
149+
{
150+
// net/url preserves a quote in the query and --access-url is
151+
// validated for its scheme only, so an operator can land this.
152+
name: "BaseURL",
153+
apply: func(_ *types.MessagePayload, h map[string]any) {
154+
h["base_url"] = func() string { return "https://coder.example.com/?q=" + injected }
155+
},
156+
},
157+
{
158+
name: "CurrentYear",
159+
apply: func(_ *types.MessagePayload, h map[string]any) {
160+
h["current_year"] = func() string { return injected }
161+
},
162+
},
163+
{
164+
name: "NotificationTemplateID",
165+
apply: func(p *types.MessagePayload, _ map[string]any) {
166+
p.NotificationTemplateID = injected
167+
},
168+
},
169+
} {
170+
t.Run(tc.name, func(t *testing.T) {
171+
t.Parallel()
172+
173+
payload := types.MessagePayload{
174+
NotificationTemplateID: "00000000-0000-0000-0000-000000000000",
175+
UserName: "Test User",
176+
Labels: map[string]string{
177+
"_subject": "Test notification",
178+
"_body": "<p>Test body</p>",
179+
},
180+
}
181+
helpers := templateHelpers()
182+
tc.apply(&payload, helpers)
183+
184+
got, err := render.GoTemplate(htmlTemplate, payload, helpers)
185+
require.NoError(t, err)
186+
187+
require.NotContains(t, got, injected,
188+
"raw value reached the rendered email: %s", got)
189+
require.Contains(t, got, html.EscapeString(injected),
190+
"the value must still be displayed, entity encoded: %s", got)
191+
})
192+
}
193+
}
194+
47195
func TestValidateFromAddr(t *testing.T) {
48196
t.Parallel()
49197

coderd/notifications/notifier.go

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

259261
var title, body string

coderd/notifications/testdata/rendered-templates/smtp/TemplateTaskCompleted.html.golden

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ Content-Type: text/html; charset=UTF-8
2929
<meta charset=3D"UTF-8" />
3030
<meta name=3D"viewport" content=3D"width=3Ddevice-width, initial-scale=
3131
=3D1.0" />
32-
<title>Task 'my-workspace' completed</title>
32+
<title>Task &#39;my-workspace&#39; completed</title>
3333
</head>
3434
<body style=3D"margin: 0; padding: 0; font-family: -apple-system, system-=
3535
ui, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarel=
@@ -44,7 +44,7 @@ er Logo" style=3D"height: 40px;" />
4444
</div>
4545
<h1 style=3D"text-align: center; font-size: 24px; font-weight: 400; m=
4646
argin: 8px 0 32px; line-height: 1.5;">
47-
Task 'my-workspace' completed
47+
Task &#39;my-workspace&#39; completed
4848
</h1>
4949
<div style=3D"line-height: 1.5;">
5050
<p>Hi Bobby,</p>

coderd/notifications/testdata/rendered-templates/smtp/TemplateTaskFailed.html.golden

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ Content-Type: text/html; charset=UTF-8
2929
<meta charset=3D"UTF-8" />
3030
<meta name=3D"viewport" content=3D"width=3Ddevice-width, initial-scale=
3131
=3D1.0" />
32-
<title>Task 'my-workspace' failed</title>
32+
<title>Task &#39;my-workspace&#39; failed</title>
3333
</head>
3434
<body style=3D"margin: 0; padding: 0; font-family: -apple-system, system-=
3535
ui, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarel=
@@ -44,7 +44,7 @@ er Logo" style=3D"height: 40px;" />
4444
</div>
4545
<h1 style=3D"text-align: center; font-size: 24px; font-weight: 400; m=
4646
argin: 8px 0 32px; line-height: 1.5;">
47-
Task 'my-workspace' failed
47+
Task &#39;my-workspace&#39; failed
4848
</h1>
4949
<div style=3D"line-height: 1.5;">
5050
<p>Hi Bobby,</p>

coderd/notifications/testdata/rendered-templates/smtp/TemplateTaskIdle.html.golden

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ Content-Type: text/html; charset=UTF-8
2929
<meta charset=3D"UTF-8" />
3030
<meta name=3D"viewport" content=3D"width=3Ddevice-width, initial-scale=
3131
=3D1.0" />
32-
<title>Task 'my-workspace' is idle</title>
32+
<title>Task &#39;my-workspace&#39; is idle</title>
3333
</head>
3434
<body style=3D"margin: 0; padding: 0; font-family: -apple-system, system-=
3535
ui, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarel=
@@ -44,7 +44,7 @@ er Logo" style=3D"height: 40px;" />
4444
</div>
4545
<h1 style=3D"text-align: center; font-size: 24px; font-weight: 400; m=
4646
argin: 8px 0 32px; line-height: 1.5;">
47-
Task 'my-workspace' is idle
47+
Task &#39;my-workspace&#39; is idle
4848
</h1>
4949
<div style=3D"line-height: 1.5;">
5050
<p>Hi Bobby,</p>

coderd/notifications/testdata/rendered-templates/smtp/TemplateTaskPaused.html.golden

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ Content-Type: text/html; charset=UTF-8
2929
<meta charset=3D"UTF-8" />
3030
<meta name=3D"viewport" content=3D"width=3Ddevice-width, initial-scale=
3131
=3D1.0" />
32-
<title>Task 'my-task' is paused</title>
32+
<title>Task &#39;my-task&#39; is paused</title>
3333
</head>
3434
<body style=3D"margin: 0; padding: 0; font-family: -apple-system, system-=
3535
ui, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarel=
@@ -44,7 +44,7 @@ er Logo" style=3D"height: 40px;" />
4444
</div>
4545
<h1 style=3D"text-align: center; font-size: 24px; font-weight: 400; m=
4646
argin: 8px 0 32px; line-height: 1.5;">
47-
Task 'my-task' is paused
47+
Task &#39;my-task&#39; is paused
4848
</h1>
4949
<div style=3D"line-height: 1.5;">
5050
<p>Hi Bobby,</p>

coderd/notifications/testdata/rendered-templates/smtp/TemplateTaskResumed.html.golden

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ Content-Type: text/html; charset=UTF-8
2929
<meta charset=3D"UTF-8" />
3030
<meta name=3D"viewport" content=3D"width=3Ddevice-width, initial-scale=
3131
=3D1.0" />
32-
<title>Task 'my-task' has resumed</title>
32+
<title>Task &#39;my-task&#39; has resumed</title>
3333
</head>
3434
<body style=3D"margin: 0; padding: 0; font-family: -apple-system, system-=
3535
ui, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarel=
@@ -44,7 +44,7 @@ er Logo" style=3D"height: 40px;" />
4444
</div>
4545
<h1 style=3D"text-align: center; font-size: 24px; font-weight: 400; m=
4646
argin: 8px 0 32px; line-height: 1.5;">
47-
Task 'my-task' has resumed
47+
Task &#39;my-task&#39; has resumed
4848
</h1>
4949
<div style=3D"line-height: 1.5;">
5050
<p>Hi Bobby,</p>

coderd/notifications/testdata/rendered-templates/smtp/TemplateTaskWorking.html.golden

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ Content-Type: text/html; charset=UTF-8
2929
<meta charset=3D"UTF-8" />
3030
<meta name=3D"viewport" content=3D"width=3Ddevice-width, initial-scale=
3131
=3D1.0" />
32-
<title>Task 'my-workspace' is working</title>
32+
<title>Task &#39;my-workspace&#39; is working</title>
3333
</head>
3434
<body style=3D"margin: 0; padding: 0; font-family: -apple-system, system-=
3535
ui, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarel=
@@ -44,7 +44,7 @@ er Logo" style=3D"height: 40px;" />
4444
</div>
4545
<h1 style=3D"text-align: center; font-size: 24px; font-weight: 400; m=
4646
argin: 8px 0 32px; line-height: 1.5;">
47-
Task 'my-workspace' is working
47+
Task &#39;my-workspace&#39; is working
4848
</h1>
4949
<div style=3D"line-height: 1.5;">
5050
<p>Hi Bobby,</p>

coderd/notifications/testdata/rendered-templates/smtp/TemplateTemplateDeleted.html.golden

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ Content-Type: text/html; charset=UTF-8
2727
<meta charset=3D"UTF-8" />
2828
<meta name=3D"viewport" content=3D"width=3Ddevice-width, initial-scale=
2929
=3D1.0" />
30-
<title>Template "Bobby's Template" deleted</title>
30+
<title>Template &#34;Bobby&#39;s Template&#34; deleted</title>
3131
</head>
3232
<body style=3D"margin: 0; padding: 0; font-family: -apple-system, system-=
3333
ui, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarel=
@@ -42,7 +42,7 @@ er Logo" style=3D"height: 40px;" />
4242
</div>
4343
<h1 style=3D"text-align: center; font-size: 24px; font-weight: 400; m=
4444
argin: 8px 0 32px; line-height: 1.5;">
45-
Template "Bobby's Template" deleted
45+
Template &#34;Bobby&#39;s Template&#34; deleted
4646
</h1>
4747
<div style=3D"line-height: 1.5;">
4848
<p>Hi Bobby,</p>

0 commit comments

Comments
 (0)