diff --git a/coderd/notifications/dispatch/smtp/html.gotmpl b/coderd/notifications/dispatch/smtp/html.gotmpl index cecba560af2..2deb5505a4a 100644 --- a/coderd/notifications/dispatch/smtp/html.gotmpl +++ b/coderd/notifications/dispatch/smtp/html.gotmpl @@ -3,7 +3,7 @@
-Hi {{ .UserName }},
+Hi {{ .UserName | html }},
{{ .Labels._body }}© {{ current_year }} Coder. All rights reserved - {{ base_url }}
-Click here to manage your notification settings
-Stop receiving emails like this
+© {{ current_year | html }} Coder. All rights reserved - {{ base_url | html }}
+ +Test body
", + }, + } + + 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() @@ -28,12 +120,9 @@ func TestSMTPHTMLTemplateEscapesAppearanceHelpers(t *testing.T) { "_body": "Test body
", }, } - 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) @@ -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": "Test body
", + }, + } + 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() diff --git a/coderd/notifications/notifier.go b/coderd/notifications/notifier.go index 19533c04942..e17243e3b6e 100644 --- a/coderd/notifications/notifier.go +++ b/coderd/notifications/notifier.go @@ -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 diff --git a/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetLimitReachedUser.html.golden b/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetLimitReachedUser.html.golden index 37db6f733cc..94d00686923 100644 --- a/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetLimitReachedUser.html.golden +++ b/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetLimitReachedUser.html.golden @@ -30,7 +30,7 @@ Content-Type: text/html; charset=UTF-8 -