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() @@ -27,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) @@ -43,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() @@ -116,3 +265,102 @@ func TestValidateFromAddr(t *testing.T) { }) } } + +func TestEncodeHeaderValue(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + value string + want string + }{ + { + name: "ascii is unchanged", + value: `User account "bobby" suspended`, + want: `User account "bobby" suspended`, + }, + { + name: "crlf is folded", + value: "Subject\r\nBcc: attacker@example.com", + want: "Subject Bcc: attacker@example.com", + }, + { + name: "bare newline is folded", + value: "Subject\nBcc: attacker@example.com", + want: "Subject Bcc: attacker@example.com", + }, + { + name: "non-ascii is q-encoded", + value: "Konto gelöscht", + want: "=?utf-8?q?Konto_gel=C3=B6scht?=", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + got := encodeHeaderValue(tc.value) + require.Equal(t, tc.want, got) + // The result must never be able to terminate its own header. + require.NotContains(t, got, "\r") + require.NotContains(t, got, "\n") + }) + } +} + +// TestEncodeHeaderValueEncodedWord covers a forged RFC 2047 encoded-word, which +// is printable ASCII and so passes mime.WordEncoder through to the client. +func TestEncodeHeaderValueEncodedWord(t *testing.T) { + t.Parallel() + + // Decodes to "URGENT: verify your account". + const forged = "=?utf-8?B?VVJHRU5UOiB2ZXJpZnkgeW91ciBhY2NvdW50?=" + got := encodeHeaderValue(forged + " shared a chat with you") + + // The forged word must not survive as something a client would decode. + require.NotContains(t, got, forged) + + // Decoded rather than compared: chunk boundaries are an implementation detail. + decoded, err := new(mime.WordDecoder).DecodeHeader(got) + require.NoError(t, err) + require.Equal(t, forged+" shared a chat with you", decoded) +} + +// TestEncodeHeaderValueFolds covers RFC 5322's 998-octet line limit, which +// mime.WordEncoder does not fold for. +func TestEncodeHeaderValueFolds(t *testing.T) { + t.Parallel() + + for name, value := range map[string]string{ + "non-ascii": strings.Repeat("é", 600), + "ascii": strings.Repeat("a b ", 400), + // A rune that does not divide evenly into the per-word budget must not + // be split across two encoded-words: each has to decode on its own. + "multibyte": strings.Repeat("日本語", 400), + // Under the raw byte limit and over it once Q-encoded, so these fail + // unless the gate measures the encoded form. + "200 accented runes": strings.Repeat("é", 200), + "300 cjk runes": strings.Repeat("日", 300), + "200 emoji": strings.Repeat("🎉", 200), + } { + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := encodeHeaderValue(value) + for _, line := range strings.Split(got, "\r\n") { + require.LessOrEqual(t, len(line), 998, + "a header line exceeds RFC 5322's limit: %d octets", len(line)) + } + // A CRLF must begin a continuation, or this is injection not folding. + for _, after := range strings.Split(got, "\r\n")[1:] { + require.True(t, strings.HasPrefix(after, " "), + "a CRLF was not followed by folding whitespace: %q", got) + } + + decoded, err := new(mime.WordDecoder).DecodeHeader(got) + require.NoError(t, err) + require.Equal(t, value, decoded) + }) + } +} diff --git a/coderd/notifications/dispatch/smtp_test.go b/coderd/notifications/dispatch/smtp_test.go index ee9b6a3d7a76d..c1624d9a326c6 100644 --- a/coderd/notifications/dispatch/smtp_test.go +++ b/coderd/notifications/dispatch/smtp_test.go @@ -4,6 +4,7 @@ import ( "bytes" "fmt" "log" + "strings" "sync" "testing" @@ -632,3 +633,130 @@ func TestSMTPEnvelopeAndHeaders(t *testing.T) { }) } } + +// TestSMTPSubjectHeader: a rendered subject must not terminate the Subject +// header, and a non-ASCII one must be RFC 2047 encoded rather than raw 8-bit. +func TestSMTPSubjectHeader(t *testing.T) { + t.Parallel() + + const ( + hello = "localhost" + to = "bob@bob.com" + body = "This is the body" + ) + + tests := []struct { + name string + // title is the rendered title template handed to the dispatcher. + title string + // wantSubject, when set, is the exact Subject header value. + wantSubject string + // wantSubjectContains are substrings the single Subject line must hold, + // used where pinning exact output would test glamour, not the header. + wantSubjectContains []string + // wantAbsent must not appear anywhere in the transmitted message. + wantAbsent string + }{ + { + name: "plain subject", + title: "This is the subject", + wantSubject: "This is the subject", + }, + { + name: "newline cannot inject a header", + // PlaintextFromMarkdown keeps the paragraph break, so this reaches + // the header writer with newlines in it. + title: "Innocent subject\n\nBcc: attacker@example.com", + wantSubjectContains: []string{"Innocent subject", "Bcc: attacker@example.com"}, + wantAbsent: "\r\nBcc:", + }, + { + name: "non-ascii subject is encoded", + title: "Konto gelöscht", + wantSubject: "=?utf-8?q?Konto_gel=C3=B6scht?=", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitShort) + logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug) + + cfg := codersdk.NotificationsEmailConfig{ + Hello: serpent.String(hello), + From: serpent.String("system@coder.com"), + } + + backend := smtptest.NewBackend(smtptest.Config{AuthMechanisms: []string{}}) + srv, listen, err := smtptest.CreateMockSMTPServer(backend, false) + require.NoError(t, err) + t.Cleanup(func() { + assert.ErrorIs(t, srv.Shutdown(ctx), smtp.ErrServerClosed) + }) + + var hp serpent.HostPort + require.NoError(t, hp.Set(listen.Addr().String())) + cfg.Smarthost = serpent.String(hp.String()) + + handler := dispatch.NewSMTPHandler(cfg, logger.Named("smtp")) + + var wg sync.WaitGroup + wg.Go(func() { + assert.NoError(t, srv.Serve(listen)) + }) + + require.Eventually(t, func() bool { + cl, err := smtptest.PingClient(listen, false, false) + if err != nil { + return false + } + _ = cl.Close() + return true + }, testutil.WaitShort, testutil.IntervalFast) + + payload := types.MessagePayload{ + Version: "1.0", + UserEmail: to, + Labels: make(map[string]string), + } + + dispatchFn, err := handler.Dispatcher(payload, tc.title, body, helpers()) + require.NoError(t, err) + + retryable, err := dispatchFn(ctx, uuid.New()) + require.NoError(t, err) + require.False(t, retryable) + + msg := backend.LastMessage() + require.NotNil(t, msg) + + // Assertions are scoped to the header block, which a blank line ends. + headers, _, found := strings.Cut(msg.Contents, "\r\n\r\n") + require.True(t, found, "message has no header/body separator") + + // The header must occupy exactly one line, whatever the value held. + require.Equal(t, 1, strings.Count(headers, "Subject: "), + "exactly one Subject header must be present") + _, after, found := strings.Cut(headers, "Subject: ") + require.True(t, found, "no Subject header in %q", headers) + subject, _, found := strings.Cut(after, "\r\n") + require.True(t, found, "Subject header is not CRLF terminated") + + if tc.wantSubject != "" { + require.Equal(t, tc.wantSubject, subject) + } + for _, want := range tc.wantSubjectContains { + require.Contains(t, subject, want) + } + if tc.wantAbsent != "" { + require.NotContains(t, headers, tc.wantAbsent, + "a value must not be able to inject an additional header") + } + + require.NoError(t, srv.Shutdown(ctx)) + wg.Wait() + }) + } +} diff --git a/coderd/notifications/notifications_test.go b/coderd/notifications/notifications_test.go index 2c76d0e2df40a..0ef64fc044c2d 100644 --- a/coderd/notifications/notifications_test.go +++ b/coderd/notifications/notifications_test.go @@ -45,6 +45,7 @@ import ( "github.com/coder/coder/v2/coderd/notifications/dispatch/smtptest" "github.com/coder/coder/v2/coderd/notifications/types" "github.com/coder/coder/v2/coderd/rbac" + markdown "github.com/coder/coder/v2/coderd/render" "github.com/coder/coder/v2/coderd/util/syncmap" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/testutil" @@ -2410,3 +2411,90 @@ func (n *acquireSignalingInterceptor) AcquireNotificationMessages(ctx context.Co n.acquiredChan <- struct{}{} return messages, err } + +// renderCapture records what the notifier renders, so a test can assert on what +// a dispatcher would receive. +type renderCapture struct { + mu sync.Mutex + title, body string + captured chan struct{} + once sync.Once +} + +func newRenderCapture() *renderCapture { + return &renderCapture{captured: make(chan struct{})} +} + +func (c *renderCapture) Dispatcher(_ types.MessagePayload, title, body string, _ template.FuncMap) (dispatch.DeliveryFunc, error) { + return func(_ context.Context, _ uuid.UUID) (bool, error) { + c.mu.Lock() + c.title, c.body = title, body + c.mu.Unlock() + c.once.Do(func() { close(c.captured) }) + return false, nil + }, nil +} + +func (c *renderCapture) wait(t *testing.T) (title, body string) { + t.Helper() + testutil.TryReceive(testutil.Context(t, testutil.WaitLong), t, c.captured) + c.mu.Lock() + defer c.mu.Unlock() + return c.title, c.body +} + +// TestNotificationMarkdownInjection is the end-to-end regression test for +// https://linear.app/codercom/issue/SEC-93. +func TestNotificationMarkdownInjection(t *testing.T) { + t.Parallel() + + const payload = "Eve\n## URGENT: SSO certificate expiring\n" + + "[Re-authenticate now](https://coder-sso.attacker.example/login)" + + ctx := dbauthz.AsNotifier(testutil.Context(t, testutil.WaitSuperLong)) + store, pubsub := dbtestutil.NewDB(t) + logger := testutil.Logger(t) + + method := database.NotificationMethodSmtp + cfg := defaultNotificationsConfig(method) + capture := newRenderCapture() + + mgr, err := notifications.NewManager(cfg, store, pubsub, defaultHelpers(), createMetrics(), logger.Named("manager")) + require.NoError(t, err) + mgr.WithHandlers(map[database.NotificationMethod]notifications.Handler{ + method: capture, + database.NotificationMethodInbox: &fakeHandler{}, + }) + t.Cleanup(func() { + assert.NoError(t, mgr.Stop(ctx)) + }) + + enq, err := notifications.NewStoreEnqueuer(cfg, store, defaultHelpers(), logger.Named("enqueuer"), quartz.NewReal()) + require.NoError(t, err) + user := createSampleUser(t, store) + + // WHEN: the notification interpolates an attacker-controlled display name + _, err = enq.Enqueue(ctx, user.ID, notifications.TemplateUserAccountSuspended, map[string]string{ + "suspended_account_name": "eve", + "suspended_account_user_name": payload, + "initiator": "admin", + "account_type": "user", + }, "test") + require.NoError(t, err) + + mgr.Run(ctx) + _, body := capture.wait(t) + + // THEN: the rendered Markdown carries no structure from the display name. + html := markdown.HTMLFromNotificationMarkdown(body) + plain, err := markdown.PlaintextFromMarkdown(body) + require.NoError(t, err) + + for _, tag := range []string{" -Hi Bobby,
diff --git a/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetWarningUser.html.golden b/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetWarningUser.html.golden index 3927ab28e31dd..4d5ffdf4744c3 100644 --- a/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetWarningUser.html.golden +++ b/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetWarningUser.html.golden @@ -29,7 +29,7 @@ Content-Type: text/html; charset=UTF-8 -Hi Bobby,
diff --git a/coderd/notifications/testdata/rendered-templates/smtp/TemplateTaskCompleted.html.golden b/coderd/notifications/testdata/rendered-templates/smtp/TemplateTaskCompleted.html.golden index 769d5595dbc3e..b4a2d53763c8c 100644 --- a/coderd/notifications/testdata/rendered-templates/smtp/TemplateTaskCompleted.html.golden +++ b/coderd/notifications/testdata/rendered-templates/smtp/TemplateTaskCompleted.html.golden @@ -29,7 +29,7 @@ Content-Type: text/html; charset=UTF-8 -Hi Bobby,
diff --git a/coderd/notifications/testdata/rendered-templates/smtp/TemplateTaskFailed.html.golden b/coderd/notifications/testdata/rendered-templates/smtp/TemplateTaskFailed.html.golden index 5d0879bc82da2..1a17d690186d4 100644 --- a/coderd/notifications/testdata/rendered-templates/smtp/TemplateTaskFailed.html.golden +++ b/coderd/notifications/testdata/rendered-templates/smtp/TemplateTaskFailed.html.golden @@ -29,7 +29,7 @@ Content-Type: text/html; charset=UTF-8 -Hi Bobby,
diff --git a/coderd/notifications/testdata/rendered-templates/smtp/TemplateTaskIdle.html.golden b/coderd/notifications/testdata/rendered-templates/smtp/TemplateTaskIdle.html.golden index 578e39e91a293..0c4fea1cf9d84 100644 --- a/coderd/notifications/testdata/rendered-templates/smtp/TemplateTaskIdle.html.golden +++ b/coderd/notifications/testdata/rendered-templates/smtp/TemplateTaskIdle.html.golden @@ -29,7 +29,7 @@ Content-Type: text/html; charset=UTF-8 -Hi Bobby,
diff --git a/coderd/notifications/testdata/rendered-templates/smtp/TemplateTaskPaused.html.golden b/coderd/notifications/testdata/rendered-templates/smtp/TemplateTaskPaused.html.golden index 58a1f098f77e0..f22fc19c5b6da 100644 --- a/coderd/notifications/testdata/rendered-templates/smtp/TemplateTaskPaused.html.golden +++ b/coderd/notifications/testdata/rendered-templates/smtp/TemplateTaskPaused.html.golden @@ -29,7 +29,7 @@ Content-Type: text/html; charset=UTF-8 -Hi Bobby,
diff --git a/coderd/notifications/testdata/rendered-templates/smtp/TemplateTaskResumed.html.golden b/coderd/notifications/testdata/rendered-templates/smtp/TemplateTaskResumed.html.golden index 81d2498b579e4..4d71d3c8ec9d2 100644 --- a/coderd/notifications/testdata/rendered-templates/smtp/TemplateTaskResumed.html.golden +++ b/coderd/notifications/testdata/rendered-templates/smtp/TemplateTaskResumed.html.golden @@ -29,7 +29,7 @@ Content-Type: text/html; charset=UTF-8 -Hi Bobby,
diff --git a/coderd/notifications/testdata/rendered-templates/smtp/TemplateTaskWorking.html.golden b/coderd/notifications/testdata/rendered-templates/smtp/TemplateTaskWorking.html.golden index 21356601f6255..b0cc318c350cc 100644 --- a/coderd/notifications/testdata/rendered-templates/smtp/TemplateTaskWorking.html.golden +++ b/coderd/notifications/testdata/rendered-templates/smtp/TemplateTaskWorking.html.golden @@ -29,7 +29,7 @@ Content-Type: text/html; charset=UTF-8 -Hi Bobby,
diff --git a/coderd/notifications/testdata/rendered-templates/smtp/TemplateTemplateDeleted.html.golden b/coderd/notifications/testdata/rendered-templates/smtp/TemplateTemplateDeleted.html.golden index 75af5a264e644..3103a58b97ab3 100644 --- a/coderd/notifications/testdata/rendered-templates/smtp/TemplateTemplateDeleted.html.golden +++ b/coderd/notifications/testdata/rendered-templates/smtp/TemplateTemplateDeleted.html.golden @@ -27,7 +27,7 @@ Content-Type: text/html; charset=UTF-8 -Hi Bobby,
diff --git a/coderd/notifications/testdata/rendered-templates/smtp/TemplateTemplateDeprecated.html.golden b/coderd/notifications/testdata/rendered-templates/smtp/TemplateTemplateDeprecated.html.golden index 70c27eed18667..6eea0d7a8dbfa 100644 --- a/coderd/notifications/testdata/rendered-templates/smtp/TemplateTemplateDeprecated.html.golden +++ b/coderd/notifications/testdata/rendered-templates/smtp/TemplateTemplateDeprecated.html.golden @@ -35,7 +35,7 @@ Content-Type: text/html; charset=UTF-8 -Hi Bobby,
diff --git a/coderd/notifications/testdata/rendered-templates/smtp/TemplateUserAccountActivated.html.golden b/coderd/notifications/testdata/rendered-templates/smtp/TemplateUserAccountActivated.html.golden index 011ef84ebfb1c..ff4ee4976af59 100644 --- a/coderd/notifications/testdata/rendered-templates/smtp/TemplateUserAccountActivated.html.golden +++ b/coderd/notifications/testdata/rendered-templates/smtp/TemplateUserAccountActivated.html.golden @@ -29,7 +29,7 @@ Content-Type: text/html; charset=UTF-8 -Hi Bobby,
diff --git a/coderd/notifications/testdata/rendered-templates/smtp/TemplateUserAccountCreated.html.golden b/coderd/notifications/testdata/rendered-templates/smtp/TemplateUserAccountCreated.html.golden index 6fc619e4129a0..fa16f497bb9ed 100644 --- a/coderd/notifications/testdata/rendered-templates/smtp/TemplateUserAccountCreated.html.golden +++ b/coderd/notifications/testdata/rendered-templates/smtp/TemplateUserAccountCreated.html.golden @@ -29,7 +29,7 @@ Content-Type: text/html; charset=UTF-8 -Hi Bobby,
diff --git a/coderd/notifications/testdata/rendered-templates/smtp/TemplateUserAccountDeleted.html.golden b/coderd/notifications/testdata/rendered-templates/smtp/TemplateUserAccountDeleted.html.golden index cfcb22beec139..e52d1b71ab892 100644 --- a/coderd/notifications/testdata/rendered-templates/smtp/TemplateUserAccountDeleted.html.golden +++ b/coderd/notifications/testdata/rendered-templates/smtp/TemplateUserAccountDeleted.html.golden @@ -29,7 +29,7 @@ Content-Type: text/html; charset=UTF-8 -Hi Bobby,
diff --git a/coderd/notifications/testdata/rendered-templates/smtp/TemplateUserAccountSuspended.html.golden b/coderd/notifications/testdata/rendered-templates/smtp/TemplateUserAccountSuspended.html.golden index 9664bc8892442..99cddd3593b11 100644 --- a/coderd/notifications/testdata/rendered-templates/smtp/TemplateUserAccountSuspended.html.golden +++ b/coderd/notifications/testdata/rendered-templates/smtp/TemplateUserAccountSuspended.html.golden @@ -30,7 +30,7 @@ Content-Type: text/html; charset=UTF-8 -Hi Bobby,
diff --git a/coderd/notifications/testdata/rendered-templates/smtp/TemplateUserRequestedOneTimePasscode.html.golden b/coderd/notifications/testdata/rendered-templates/smtp/TemplateUserRequestedOneTimePasscode.html.golden index 12e29c47ed078..819fc0d0e8504 100644 --- a/coderd/notifications/testdata/rendered-templates/smtp/TemplateUserRequestedOneTimePasscode.html.golden +++ b/coderd/notifications/testdata/rendered-templates/smtp/TemplateUserRequestedOneTimePasscode.html.golden @@ -56,10 +56,10 @@ argin: 8px 0 32px; line-height: 1.5;">Hi Bobby,
diff --git a/coderd/notifications/testdata/rendered-templates/smtp/TemplateWorkspaceAutostopReminder.html.golden b/coderd/notifications/testdata/rendered-templates/smtp/TemplateWorkspaceAutostopReminder.html.golden index 350896eb0eb3b..175cf87ece91c 100644 --- a/coderd/notifications/testdata/rendered-templates/smtp/TemplateWorkspaceAutostopReminder.html.golden +++ b/coderd/notifications/testdata/rendered-templates/smtp/TemplateWorkspaceAutostopReminder.html.golden @@ -29,7 +29,7 @@ Content-Type: text/html; charset=UTF-8 -Hi Bobby,
diff --git a/coderd/notifications/testdata/rendered-templates/smtp/TemplateWorkspaceCreated.html.golden b/coderd/notifications/testdata/rendered-templates/smtp/TemplateWorkspaceCreated.html.golden index 9fccba0b1f239..97f6e720a2861 100644 --- a/coderd/notifications/testdata/rendered-templates/smtp/TemplateWorkspaceCreated.html.golden +++ b/coderd/notifications/testdata/rendered-templates/smtp/TemplateWorkspaceCreated.html.golden @@ -28,7 +28,7 @@ Content-Type: text/html; charset=UTF-8 -Hi Bobby,
diff --git a/coderd/notifications/testdata/rendered-templates/smtp/TemplateWorkspaceDeleted.html.golden b/coderd/notifications/testdata/rendered-templates/smtp/TemplateWorkspaceDeleted.html.golden index fcc9b57f17b9f..46ebe956cb2f8 100644 --- a/coderd/notifications/testdata/rendered-templates/smtp/TemplateWorkspaceDeleted.html.golden +++ b/coderd/notifications/testdata/rendered-templates/smtp/TemplateWorkspaceDeleted.html.golden @@ -31,7 +31,7 @@ Content-Type: text/html; charset=UTF-8 -Hi Bobby,
diff --git a/coderd/notifications/testdata/rendered-templates/smtp/TemplateWorkspaceDeleted_CustomAppearance.html.golden b/coderd/notifications/testdata/rendered-templates/smtp/TemplateWorkspaceDeleted_CustomAppearance.html.golden index 7c1f7192b1fc8..cf1e96a2edac3 100644 --- a/coderd/notifications/testdata/rendered-templates/smtp/TemplateWorkspaceDeleted_CustomAppearance.html.golden +++ b/coderd/notifications/testdata/rendered-templates/smtp/TemplateWorkspaceDeleted_CustomAppearance.html.golden @@ -31,7 +31,7 @@ Content-Type: text/html; charset=UTF-8 -Hi Bobby,
diff --git a/coderd/notifications/testdata/rendered-templates/smtp/TemplateWorkspaceDormant.html.golden b/coderd/notifications/testdata/rendered-templates/smtp/TemplateWorkspaceDormant.html.golden index ea9e1b697957b..a6ee986bd9d43 100644 --- a/coderd/notifications/testdata/rendered-templates/smtp/TemplateWorkspaceDormant.html.golden +++ b/coderd/notifications/testdata/rendered-templates/smtp/TemplateWorkspaceDormant.html.golden @@ -34,7 +34,7 @@ Content-Type: text/html; charset=UTF-8 -Hi Bobby,
diff --git a/coderd/notifications/testdata/rendered-templates/smtp/TemplateWorkspaceDormant_NoAutoDelete.html.golden b/coderd/notifications/testdata/rendered-templates/smtp/TemplateWorkspaceDormant_NoAutoDelete.html.golden index e41eeb19fee03..97c4d95bdf8e2 100644 --- a/coderd/notifications/testdata/rendered-templates/smtp/TemplateWorkspaceDormant_NoAutoDelete.html.golden +++ b/coderd/notifications/testdata/rendered-templates/smtp/TemplateWorkspaceDormant_NoAutoDelete.html.golden @@ -31,7 +31,7 @@ Content-Type: text/html; charset=UTF-8 -Hi Bobby,
diff --git a/coderd/notifications/testdata/rendered-templates/smtp/TemplateWorkspaceManualBuildFailed.html.golden b/coderd/notifications/testdata/rendered-templates/smtp/TemplateWorkspaceManualBuildFailed.html.golden index 2f7bb2771c8a9..f1b67ebc39c50 100644 --- a/coderd/notifications/testdata/rendered-templates/smtp/TemplateWorkspaceManualBuildFailed.html.golden +++ b/coderd/notifications/testdata/rendered-templates/smtp/TemplateWorkspaceManualBuildFailed.html.golden @@ -29,7 +29,7 @@ Content-Type: text/html; charset=UTF-8 -Hi Bobby,
diff --git a/coderd/notifications/testdata/rendered-templates/smtp/TemplateWorkspaceManuallyUpdated.html.golden b/coderd/notifications/testdata/rendered-templates/smtp/TemplateWorkspaceManuallyUpdated.html.golden index 0e70293b09065..d971cd94c1541 100644 --- a/coderd/notifications/testdata/rendered-templates/smtp/TemplateWorkspaceManuallyUpdated.html.golden +++ b/coderd/notifications/testdata/rendered-templates/smtp/TemplateWorkspaceManuallyUpdated.html.golden @@ -31,7 +31,8 @@ Content-Type: text/html; charset=UTF-8 -Hi Bobby,
diff --git a/coderd/notifications/testdata/rendered-templates/smtp/TemplateWorkspaceOutOfDisk.html.golden b/coderd/notifications/testdata/rendered-templates/smtp/TemplateWorkspaceOutOfDisk.html.golden index 1e65a1eab12fc..15bffe4a810d7 100644 --- a/coderd/notifications/testdata/rendered-templates/smtp/TemplateWorkspaceOutOfDisk.html.golden +++ b/coderd/notifications/testdata/rendered-templates/smtp/TemplateWorkspaceOutOfDisk.html.golden @@ -27,7 +27,8 @@ Content-Type: text/html; charset=UTF-8 -Hi Bobby,
diff --git a/coderd/notifications/testdata/rendered-templates/smtp/TemplateYourAccountSuspended.html.golden b/coderd/notifications/testdata/rendered-templates/smtp/TemplateYourAccountSuspended.html.golden index 277195a2bd427..74dee9477963e 100644 --- a/coderd/notifications/testdata/rendered-templates/smtp/TemplateYourAccountSuspended.html.golden +++ b/coderd/notifications/testdata/rendered-templates/smtp/TemplateYourAccountSuspended.html.golden @@ -25,7 +25,7 @@ Content-Type: text/html; charset=UTF-8 -Hi Bobby,
diff --git a/coderd/notifications/testdata/rendered-templates/webhook/TemplateWorkspaceResourceReplaced.json.golden b/coderd/notifications/testdata/rendered-templates/webhook/TemplateWorkspaceResourceReplaced.json.golden index 09bf9431cdeed..6ae1b693ac2df 100644 --- a/coderd/notifications/testdata/rendered-templates/webhook/TemplateWorkspaceResourceReplaced.json.golden +++ b/coderd/notifications/testdata/rendered-templates/webhook/TemplateWorkspaceResourceReplaced.json.golden @@ -38,5 +38,5 @@ "title": "There might be a problem with a recently claimed prebuilt workspace", "title_markdown": "There might be a problem with a recently claimed prebuilt workspace", "body": "Workspace my-workspace was claimed from a prebuilt workspace by prebuilds-claimer.\n\nDuring the claim, Terraform destroyed and recreated the following resources\nbecause one or more immutable attributes changed:\n\ndocker_container[0] was replaced due to changes to env, hostname\n\nWhen Terraform must change an immutable attribute, it replaces the entire resource.\nIf you’re using prebuilds to speed up provisioning, unexpected replacements will slow down\nworkspace startup—even when claiming a prebuilt environment.\n\nFor tips on preventing replacements and improving claim performance, see this guide (https://coder.com/docs/admin/templates/extending-templates/prebuilt-workspaces#preventing-resource-replacement).\n\nNOTE: this prebuilt workspace used the particle-accelerator preset.", - "body_markdown": "\nWorkspace **my-workspace** was claimed from a prebuilt workspace by **prebuilds-claimer**.\n\nDuring the claim, Terraform destroyed and recreated the following resources\nbecause one or more immutable attributes changed:\n\n- _docker_container[0]_ was replaced due to changes to _env, hostname_\n\n\nWhen Terraform must change an immutable attribute, it replaces the entire resource.\nIf you’re using prebuilds to speed up provisioning, unexpected replacements will slow down\nworkspace startup—even when claiming a prebuilt environment.\n\nFor tips on preventing replacements and improving claim performance, see [this guide](https://coder.com/docs/admin/templates/extending-templates/prebuilt-workspaces#preventing-resource-replacement).\n\nNOTE: this prebuilt workspace used the **particle-accelerator** preset.\n" + "body_markdown": "\nWorkspace **my-workspace** was claimed from a prebuilt workspace by **prebuilds-claimer**.\n\nDuring the claim, Terraform destroyed and recreated the following resources\nbecause one or more immutable attributes changed:\n\n- _docker_container\\[0\\]_ was replaced due to changes to _env, hostname_\n\n\nWhen Terraform must change an immutable attribute, it replaces the entire resource.\nIf you’re using prebuilds to speed up provisioning, unexpected replacements will slow down\nworkspace startup—even when claiming a prebuilt environment.\n\nFor tips on preventing replacements and improving claim performance, see [this guide](https://coder.com/docs/admin/templates/extending-templates/prebuilt-workspaces#preventing-resource-replacement).\n\nNOTE: this prebuilt workspace used the **particle-accelerator** preset.\n" } \ No newline at end of file diff --git a/coderd/notifications/types/escape.go b/coderd/notifications/types/escape.go new file mode 100644 index 0000000000000..d267cc7651ed6 --- /dev/null +++ b/coderd/notifications/types/escape.go @@ -0,0 +1,58 @@ +package types + +import "github.com/coder/coder/v2/coderd/render" + +// EscapedForMarkdown returns a copy of the payload whose string values have +// Markdown structure neutralized, for rendering the title and body templates. +// The receiver is left untouched: the stored payload keeps the values as they +// were enqueued, which is what the webhook dispatcher surfaces to consumers and +// what the SMTP dispatcher escapes at its own HTML sinks. +func (p MessagePayload) EscapedForMarkdown() MessagePayload { + out := p + out.UserName = render.EscapeMarkdown(p.UserName) + + if p.Labels != nil { + labels := make(map[string]string, len(p.Labels)) + for k, v := range p.Labels { + labels[k] = render.EscapeMarkdown(v) + } + out.Labels = labels + } + + if p.Data != nil { + data := make(map[string]any, len(p.Data)) + for k, v := range p.Data { + data[k] = escapeValue(v) + } + out.Data = data + } + return out +} + +// escapeValue walks a decoded JSON value and escapes its string leaves. Numbers, +// booleans and nulls pass through unchanged so that template comparisons such as +// `{{if gt $version.failed_count 1}}` keep working. +// +// Nested keys are escaped too: a key is content whenever a template ranges with +// two variables, as the resource replacements body does over Terraform resource +// addresses. +func escapeValue(v any) any { + switch t := v.(type) { + case string: + return render.EscapeMarkdown(t) + case map[string]any: + out := make(map[string]any, len(t)) + for k, vv := range t { + out[render.EscapeMarkdown(k)] = escapeValue(vv) + } + return out + case []any: + out := make([]any, len(t)) + for i, vv := range t { + out[i] = escapeValue(vv) + } + return out + default: + return v + } +} diff --git a/coderd/notifications/types/escape_test.go b/coderd/notifications/types/escape_test.go new file mode 100644 index 0000000000000..f10e332a42f94 --- /dev/null +++ b/coderd/notifications/types/escape_test.go @@ -0,0 +1,132 @@ +package types_test + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/coderd/notifications/types" +) + +func TestEscapedForMarkdown(t *testing.T) { + t.Parallel() + + t.Run("EscapesLabelsAndUserName", func(t *testing.T) { + t.Parallel() + + payload := types.MessagePayload{ + UserName: "Eve [Re-auth](https://attacker.example)", + Labels: map[string]string{ + "suspended_account_user_name": "Eve\n## URGENT\n[Re-auth](https://attacker.example)", + "limit_source": "user_override", + }, + } + + escaped := payload.EscapedForMarkdown() + require.Equal(t, `Eve \[Re-auth\]\(https://attacker.example\)`, escaped.UserName) + require.NotContains(t, escaped.Labels["suspended_account_user_name"], "[Re-auth](") + // Control values must survive so template conditionals keep matching. + require.Equal(t, "user_override", escaped.Labels["limit_source"]) + }) + + t.Run("LeavesReceiverUntouched", func(t *testing.T) { + t.Parallel() + + // The webhook dispatcher surfaces the payload verbatim, so the original + // must not mutate. + payload := types.MessagePayload{ + UserName: "Eve [x](https://attacker.example)", + Labels: map[string]string{"name": "bobby-workspace", "risky": "[x](https://attacker.example)"}, + Data: map[string]any{"user": map[string]any{"name": "[x](https://attacker.example)"}}, + } + + _ = payload.EscapedForMarkdown() + + require.Equal(t, "Eve [x](https://attacker.example)", payload.UserName) + require.Equal(t, "[x](https://attacker.example)", payload.Labels["risky"]) + require.Equal(t, "[x](https://attacker.example)", + payload.Data["user"].(map[string]any)["name"]) + }) + + t.Run("RecursesIntoData", func(t *testing.T) { + t.Parallel() + + payload := types.MessagePayload{ + Data: map[string]any{ + "user": map[string]any{"name": "[x](https://attacker.example)"}, + "archived_chats": []any{ + map[string]any{"title": "[x](https://attacker.example)"}, + }, + }, + } + + escaped := payload.EscapedForMarkdown() + require.Equal(t, `\[x\]\(https://attacker.example\)`, + escaped.Data["user"].(map[string]any)["name"]) + require.Equal(t, `\[x\]\(https://attacker.example\)`, + escaped.Data["archived_chats"].([]any)[0].(map[string]any)["title"]) + }) + + t.Run("PreservesNonStringLeaves", func(t *testing.T) { + t.Parallel() + + // Body templates compare numbers, as {{if gt $version.failed_count 1}} + // does, so coercing them to strings would break the comparison. + payload := types.MessagePayload{ + Data: map[string]any{ + "failed_count": 3.0, + "enabled": true, + "absent": nil, + "versions": []any{map[string]any{"failed_count": 1.0}}, + }, + } + + escaped := payload.EscapedForMarkdown() + require.Equal(t, 3.0, escaped.Data["failed_count"]) + require.Equal(t, true, escaped.Data["enabled"]) + require.Nil(t, escaped.Data["absent"]) + require.Equal(t, 1.0, escaped.Data["versions"].([]any)[0].(map[string]any)["failed_count"]) + }) + + t.Run("NilMapsStayNil", func(t *testing.T) { + t.Parallel() + + escaped := types.MessagePayload{}.EscapedForMarkdown() + require.Nil(t, escaped.Labels) + require.Nil(t, escaped.Data) + }) + + t.Run("EscapesNestedMapKeys", func(t *testing.T) { + t.Parallel() + + // A nested key is content when a template ranges with two variables. + payload := types.MessagePayload{ + Data: map[string]any{ + "replacements": map[string]any{ + "[Re-auth](https://attacker.example/login)": "paths", + "null_resource.ok": "other", + }, + }, + } + + escaped := payload.EscapedForMarkdown() + replacements, ok := escaped.Data["replacements"].(map[string]any) + require.True(t, ok) + require.Contains(t, replacements, `\[Re-auth\]\(https://attacker.example/login\)`) + require.NotContains(t, replacements, "[Re-auth](https://attacker.example/login)") + // A key with nothing to escape must stay resolvable by name. + require.Contains(t, replacements, "null_resource.ok") + }) + + t.Run("LeavesTopLevelDataKeysAlone", func(t *testing.T) { + t.Parallel() + + // Top-level .Data keys are dereferenced by name, not content, so escaping + // one breaks the lookup. + payload := types.MessagePayload{ + Data: map[string]any{"failed_builds": []any{"x"}}, + } + + require.Contains(t, payload.EscapedForMarkdown().Data, "failed_builds") + }) +} diff --git a/coderd/render/escape.go b/coderd/render/escape.go new file mode 100644 index 0000000000000..7c7d2b65f79db --- /dev/null +++ b/coderd/render/escape.go @@ -0,0 +1,188 @@ +package render + +import "strings" + +// Character classes for EscapeMarkdown, split by where each character carries +// structural meaning. The split is what keeps escaping away from the enum-like +// label values that body templates compare with `eq`, such as "user_override", +// "bobby-workspace" and "1.5": escaping one changes template control flow. +const ( + // inlineCritical characters can produce a link, an image, an angle autolink, + // or forge an escape from anywhere in a value, so they are always escaped. + // + // Backtick is here, not in blockStart, because a fence's info string is an + // HTML sink: gomarkdown writes it into class="language-..." unescaped, and + // SkipHTML does not apply to a CodeBlock node. Escaping only the leading + // backtick would leave two, which open an inline code span. + inlineCritical = "\\[]()!<`" + + // blockStart characters carry structural meaning only as the first + // non-space character of a line, so they are escaped only there. + blockStart = `#-+.>|` + + // leadingEmphasis characters carry inline meaning anywhere but also open a + // block construct in leading position: "* " starts a bullet list, and three + // or more of either character starts a thematic break. Escaping them only + // there costs emphasis that begins on a line boundary. + leadingEmphasis = `*_` + + // foldStart characters also carry meaning only at the start of a line, but + // glamour does not honor a backslash before them, so escaping would leave a + // literal backslash in the plaintext part. The preceding line break becomes + // a space instead, denying them the line-start position. + // + // ":" is here for the GFM delimiter row ":-- | --:" as well as definition + // lists. Escaping "|" does not reach that row: its pipes are mid-line. + foldStart = `=~:` + + // maxLeadingSpaces is the widest indentation a line may keep, since four + // spaces open an indented code block and a space cannot be escaped. + maxLeadingSpaces = 3 +) + +// EscapeMarkdown neutralizes Markdown structure in an untrusted value so that it +// renders as literal text through both HTMLFromNotificationMarkdown and +// PlaintextFromMarkdown. Line breaks are preserved so multi-line values keep +// their shape. Other control characters are dropped, being the carrier for SMTP +// header injection. +// +// Known residual: a template that wraps the value in a code span. CommonMark +// does not process escapes inside one, so the backslashes emitted here reach +// the reader. Nothing here can detect that, since the sink is decided after +// this runs. +func EscapeMarkdown(s string) string { + if s == "" { + return s + } + + lines := strings.Split(stripControl(s), "\n") + var b strings.Builder + b.Grow(len(s) + len(s)/8) + + for i, line := range lines { + if i > 0 { + // Joining a fold-start line to the previous one takes it out of + // leading position. + if opensFoldConstruct(line) { + _ = b.WriteByte(' ') + } else { + _ = b.WriteByte('\n') + } + } + // The first line has no preceding break to fold, so escaping is the only + // lever left there. + _, _ = b.WriteString(escapeLine(line, i == 0 && isLeadingFoldConstruct(line))) + } + return b.String() +} + +// stripControl keeps line breaks, turns the other whitespace controls into +// spaces and drops the rest. Carriage returns are folded rather than kept so a +// value cannot terminate an SMTP header. +func stripControl(s string) string { + if strings.IndexFunc(s, isStrippable) < 0 { + return s + } + var b strings.Builder + b.Grow(len(s)) + for _, r := range s { + switch { + case r == '\n': + _, _ = b.WriteRune(r) + case r == '\r' || r == '\t' || r == '\v' || r == '\f': + _, _ = b.WriteRune(' ') + case r < 0x20 || r == 0x7f: + // Dropped. + default: + _, _ = b.WriteRune(r) + } + } + return b.String() +} + +func isStrippable(r rune) bool { + return r != '\n' && (r < 0x20 || r == 0x7f) +} + +// escapeLine escapes one line's structural characters and truncates its +// indentation to maxLeadingSpaces. +// +// escapeFold additionally escapes a leading "=" or "~". Only EscapeMarkdown's +// first line passes it, and only when that line really is a fold construct. +func escapeLine(line string, escapeFold bool) string { + var b strings.Builder + b.Grow(len(line)) + + leading := true + spaces := 0 + // digitRun reports whether the line so far is nothing but indentation and + // digits, which is the only position where "." opens an ordered list. + digitRun := false + for i, r := range line { + switch { + case r < 0x80 && strings.ContainsRune(inlineCritical, r): + _ = b.WriteByte('\\') + _, _ = b.WriteRune(r) + case leading && r == ' ': + // Indentation keeps the next character in leading position. + if spaces < maxLeadingSpaces { + _, _ = b.WriteRune(r) + spaces++ + } + continue + case leading && r < 0x80 && strings.ContainsRune(blockStart+leadingEmphasis, r): + _ = b.WriteByte('\\') + _, _ = b.WriteRune(r) + case leading && escapeFold && (r == '=' || r == '~'): + _ = b.WriteByte('\\') + _, _ = b.WriteRune(r) + case digitRun && r == '.' && closesMarker(line, i): + // The "1." of an ordered list. Its sibling "1)" needs no case + // because ")" is inlineCritical and is always escaped. + _ = b.WriteByte('\\') + _, _ = b.WriteRune(r) + default: + _, _ = b.WriteRune(r) + } + digitRun = (leading || digitRun) && r >= '0' && r <= '9' + leading = false + } + return b.String() +} + +// closesMarker reports whether the single-byte list-marker delimiter at i is +// followed by a space or ends the line, as CommonMark requires of a marker. +// That requirement is what keeps a value such as "1.5" out of the escaped set. +// Tabs need no handling: stripControl has already folded them into spaces. +func closesMarker(line string, i int) bool { + return i+1 == len(line) || line[i+1] == ' ' +} + +// opensFoldConstruct reports whether a line's first non-space character is a +// foldStart character. Approximate on purpose: it only decides whether to drop +// a line break, which costs nothing. +func opensFoldConstruct(line string) bool { + t := strings.TrimLeft(line, " ") + if t == "" { + return false + } + return strings.ContainsRune(foldStart, rune(t[0])) +} + +// isLeadingFoldConstruct reports whether a line is itself a tilde fence opener +// or a Setext "=" underline, rather than merely starting with one of those +// characters. Exact, because it governs escaping and the backslash is visible: +// "=> next" must not acquire one, while "~~~" must, since an unterminated fence +// at the start of a title renders the Subject,"), "
") + if html == wantLiteral { + _, _ = gotHTML.WriteRune(r) + } + + plain, err := PlaintextFromMarkdown(escaped) + require.NoError(t, err) + if plain == "X"+string(r)+"Y" { + _, _ = gotPlain.WriteRune(r) + } + } + + require.Equal(t, wantHTML, gotHTML.String(), + "the set of characters gomarkdown honors as escapes has changed; re-derive EscapeMarkdown's character classes") + require.Equal(t, wantPlain, gotPlain.String(), + "the set of characters glamour honors as escapes has changed; re-derive EscapeMarkdown's character classes") + + // Every character EscapeMarkdown escapes must be honored by both renderers. + for _, r := range inlineCritical + blockStart + leadingEmphasis { + assert.Contains(t, wantHTML, string(r), "gomarkdown does not honor \\%s", string(r)) + assert.Contains(t, wantPlain, string(r), "glamour does not honor \\%s", string(r)) + } + // foldStart characters are folded precisely because they are not escapable. + for _, r := range foldStart { + assert.NotContains(t, wantPlain, string(r), + "glamour now honors \\%s, so it could be escaped instead of folded", string(r)) + } +} + +// TestEscapeMarkdownControlValues guards the label values body templates compare +// with `eq`. Escaping one silently changes template control flow. +func TestEscapeMarkdownControlValues(t *testing.T) { + t.Parallel() + + for _, v := range []string{ + "user_override", // migrations/000553, {{if eq .Labels.limit_source "user_override"}} + "service", // migrations/000568, {{if eq .Labels.account_type "service"}} + "0", // migrations/000480, {{if eq .Data.retention_days "0"}} + "autobuild", + "initiator", + "user-override", + "1.5", + "10.0.0.1", + "bobby-workspace", + } { + require.Equal(t, v, EscapeMarkdown(v), "escaping changed a control value") + } +} + +func TestEscapeMarkdown(t *testing.T) { + t.Parallel() + + // Emphasis and code tags are accepted residuals: no destination. + structuralTags := []string{ + ""}, + // Neutralized by autolinking being off. See + // TestEscapeMarkdownNoAutolink. + {name: "BareURL", value: "Eve https://attacker.example/login", inertRaw: true}, + {name: "Mailto", value: "Eve mailto:eve@attacker.example", inertRaw: true}, + {name: "ATXHeading", value: "Eve\n## URGENT"}, + {name: "SetextH1", value: "URGENT: re-auth required\n===\nx"}, + {name: "SetextH1Spaced", value: "Eve\n=== \n#### x"}, + {name: "SetextH1Repeated", value: "Eve\n===\n===\nx"}, + {name: "SetextH2", value: "Eve\n---\nx"}, + {name: "ThematicBreak", value: "Eve\n----\nx"}, + {name: "ThematicBreakStars", value: "Eve\n***\nnext"}, + {name: "ThematicBreakUnderscores", value: "Eve\n___\nnext"}, + {name: "ThematicBreakSpacedStars", value: "Eve\n* * *\nnext"}, + {name: "ThematicBreakSpacedUnderscores", value: "Eve\n_ _ _\nnext"}, + {name: "BulletList", value: "Eve\n- one\n- two"}, + {name: "BulletListStar", value: "Eve\n* one\n* two"}, + {name: "BulletListStarIndented", value: "Eve\n * one\n * two"}, + {name: "OrderedList", value: "Eve\n1. one\n2. two"}, + {name: "OrderedListMultiDigit", value: "Eve\n99. one\n100. two"}, + {name: "OrderedListParen", value: "Eve\n1) one\n2) two"}, + {name: "Blockquote", value: "Eve\n> quoted"}, + // Neutralized by the Tables extension being off; the escaper's own + // handling is covered by TestEscapeMarkdownColon. + {name: "Table", value: "a | b\n--- | ---\nc | d", inertRaw: true}, + // Neutralized by the safelink policy. See + // TestEscapeMarkdownNoAutolink/SafelinkRejectsUnsafeSchemes. + {name: "JavascriptScheme", value: "[click](javascript:alert(1))", inertRaw: true}, + // Asserts escaping does not re-enable the value's own backslashes. + {name: "EscapeForging", value: `Eve \[Re-auth\](https://attacker.example)`, inertRaw: true}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + // Rendered twice, the second with line breaks doubled: only a + // blank line lets a block construct interrupt a paragraph. + rawProducedTag := false + for _, value := range []string{tc.value, strings.ReplaceAll(tc.value, "\n", "\n\n")} { + escaped := EscapeMarkdown(value) + html := HTMLFromNotificationMarkdown(suspendedBody(escaped)) + plain, err := PlaintextFromMarkdown(suspendedBody(escaped)) + require.NoError(t, err) + raw := HTMLFromNotificationMarkdown(suspendedBody(value)) + + for _, tag := range structuralTags { + assert.NotContains(t, html, tag, "value %q rendered HTML: %s", value, html) + rawProducedTag = rawProducedTag || strings.Contains(raw, tag) + } + // A backslash the value did not contain means a character the + // renderer does not honor was escaped. + if !strings.Contains(value, `\`) { + assert.NotContains(t, html, `\`, "value %q leaked a literal backslash into HTML", value) + assert.NotContains(t, plain, `\`, "value %q leaked a literal backslash into plaintext", value) + } + } + + // Without this, a row whose value can never reach a line-start + // position passes whether or not EscapeMarkdown runs. + if !tc.inertRaw { + assert.True(t, rawProducedTag, + "vacuous row: %q produces no structural tag even unescaped, so the assertions above guard nothing; fix the value or set inertRaw with a reason", + tc.value) + } + }) + } + }) + + t.Run("PreservesBenignValues", func(t *testing.T) { + t.Parallel() + + // Also why this change leaves the notification golden files untouched. + for _, value := range []string{ + "William Tables", + "bobby-workspace", + "Bobby's Template", + "O'Brien-Smith (Eng) 100%", + "autodeleted due to dormancy (autobuild)", + "José Müller 日本語", + // Documented multi-line custom notification, see + // docs/admin/monitoring/notifications/index.md. + "Test results:\n • ✅ success", + "Test results:\n • ❌ failed (3 tests failed)", + } { + t.Run(value, func(t *testing.T) { + t.Parallel() + + escaped := EscapeMarkdown(value) + require.Equal(t, HTMLFromNotificationMarkdown(suspendedBody(value)), HTMLFromNotificationMarkdown(suspendedBody(escaped))) + + wantPlain, err := PlaintextFromMarkdown(suspendedBody(value)) + require.NoError(t, err) + gotPlain, err := PlaintextFromMarkdown(suspendedBody(escaped)) + require.NoError(t, err) + require.Equal(t, wantPlain, gotPlain) + }) + } + }) + + t.Run("ControlCharacters", func(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + name string + value string + want string + }{ + {"KeepsNewlines", "a\nb", "a\nb"}, + {"FoldsCarriageReturn", "a\r\nb", "a \nb"}, + {"FoldsTab", "a\tb", "a b"}, + {"FoldsVerticalTab", "a\vb", "a b"}, + {"DropsNul", "a\x00b", "ab"}, + {"DropsBell", "a\x07b", "ab"}, + {"DropsDelete", "a\x7fb", "ab"}, + {"Empty", "", ""}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + require.Equal(t, tc.want, EscapeMarkdown(tc.value)) + }) + } + }) + + t.Run("AngleBracketsAreNeutralised", func(t *testing.T) { + t.Parallel() + + // "Ops