Files
coder/coderd/notifications/dispatch/smtp_test.go
T
Bobby Ho 9e8075db0f fix: prevent markdown injection in notifications (#28340)
First of two PRs.

Notification title and body templates are Markdown authored by Coder,
but the label values interpolated into them are user-controlled and were
substituted through `text/template`, which does no escaping. Those
values arrive from user profile fields and from OIDC/GitHub name claims.

This PR:

- Neutralizes Markdown structure in label, data, and `UserName` values
before they reach the template. Applied in `notifier.prepare`, so
already-queued messages are covered and the stored payload keeps its
original values for webhook consumers. Nested `.Data` map keys are
escaped too: one shipped template prints a key, and those keys are
Terraform resource addresses.
- Narrows the notification Markdown grammar to what templates actually
use. `CommonExtensions` enabled Tables, DefinitionLists and MathJax,
each openable from a value and used by no template. Autolink stays off
so a URL in a value cannot become an anchor.
- Enables `html.Safelink`, restricting generated hrefs to safe schemes,
and guards the panic it exposes: `parser.IsSafeURL` slices a destination
before bounds-checking it, so `[docs]()` crashed both renderers.
- Folds line breaks out of the `Subject:` header and encodes it, fixing
a pre-existing RFC 2047 violation for non-ASCII subjects, a forged
encoded-word that let a value choose the displayed subject, and headers
running past RFC 5322's 998-octet line limit.

Escaping is narrow on purpose, split by where each character carries
meaning:

- `` \[]()!<` `` everywhere. Backtick is in this group because a fenced
block's info string is an HTML sink: gomarkdown writes it into
`class="language-..."` unescaped, and `SkipHTML` does not apply to a
`CodeBlock` node, so a value that closes the attribute and the tag
injects live markup.
- `#-+.>|` only in leading position, so values like `bobby-workspace`
and `1.5` are untouched.
- `=`, `~` and `:` are not escapable by both renderers, so the preceding
line break is folded instead. `:` opens a definition list and a GFM
table delimiter row that escaping `|` cannot reach. A value's *first*
line has no preceding break to fold, so a real tilde fence or `===`
underline is escaped there instead, accepting a visible backslash: an
unterminated `~~~` at the start of a title otherwise renders the
Subject, `<title>` and heading empty.
- Leading indentation is truncated to three spaces. Four open an
indented code block and a space has no escape.
- Emphasis characters are left alone. Escaping `_` corrupts label values
such as `user_override` that body templates compare with `eq`, which
silently drops content from the rendered email.

One golden file changes: the resource replacements `body_markdown` now
reads `docker_container\[0\]`, from the map-key escaping above. Every
other golden is byte-identical.

**One known residual**, pinned by a test that fails if it closes:
CommonMark does not process escapes inside a code span, so where a
template wraps a value in one, as the workspace out-of-disk body does,
the escaper's own backslashes reach the reader. That depends on where
the value lands rather than what it contains, which a pre-render escaper
cannot see. This narrows the class rather than closing it.

**#28397 completes the fix and is stacked on this branch. This PR should
not merge without it.**

Escaping here cannot reach the SMTP HTML template's sinks, by design
rather than by oversight: the subject is produced by
`PlaintextFromMarkdown`, which strips exactly the backslashes added
here, and `html.gotmpl` then interpolated the result through
`text/template`. On this branch alone, a label value still reaches the
Subject, `<title>` and `<h1>` as live markup. #28397 escapes at those
sinks with `| html`, which is the only place the information needed to
escape correctly exists.
2026-08-25 08:12:00 -07:00

763 lines
20 KiB
Go

package dispatch_test
import (
"bytes"
"fmt"
"log"
"strings"
"sync"
"testing"
"github.com/emersion/go-sasl"
"github.com/emersion/go-smtp"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/goleak"
"cdr.dev/slog/v3"
"cdr.dev/slog/v3/sloggers/slogtest"
"github.com/coder/coder/v2/coderd/notifications/dispatch"
"github.com/coder/coder/v2/coderd/notifications/dispatch/smtptest"
"github.com/coder/coder/v2/coderd/notifications/types"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/testutil"
"github.com/coder/serpent"
)
func TestMain(m *testing.M) {
goleak.VerifyTestMain(m, testutil.GoleakOptions...)
}
func TestSMTP(t *testing.T) {
t.Parallel()
const (
username = "bob"
password = "🤫"
hello = "localhost"
identity = "robert"
from = "system@coder.com"
to = "bob@bob.com"
subject = "This is the subject"
body = "This is the body"
caFile = "smtptest/fixtures/ca.crt"
certFile = "smtptest/fixtures/server.crt"
keyFile = "smtptest/fixtures/server.key"
)
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true, IgnoredErrorIs: []error{}}).Leveled(slog.LevelDebug)
tests := []struct {
name string
cfg codersdk.NotificationsEmailConfig
toAddrs []string
authMechs []string
expectedAuthMeth string
expectedErr string
retryable bool
useTLS bool
failOnDataFn func() error
}{
/**
* LOGIN auth mechanism
*/
{
name: "LOGIN auth",
authMechs: []string{sasl.Login},
cfg: codersdk.NotificationsEmailConfig{
Hello: hello,
From: from,
Auth: codersdk.NotificationsEmailAuthConfig{
Username: username,
Password: password,
},
},
toAddrs: []string{to},
expectedAuthMeth: sasl.Login,
},
{
name: "invalid LOGIN auth user",
authMechs: []string{sasl.Login},
cfg: codersdk.NotificationsEmailConfig{
Hello: hello,
From: from,
Auth: codersdk.NotificationsEmailAuthConfig{
Username: username + "-wrong",
Password: password,
},
},
toAddrs: []string{to},
expectedAuthMeth: sasl.Login,
expectedErr: "unknown user",
retryable: true,
},
{
name: "invalid LOGIN auth credentials",
authMechs: []string{sasl.Login},
cfg: codersdk.NotificationsEmailConfig{
Hello: hello,
From: from,
Auth: codersdk.NotificationsEmailAuthConfig{
Username: username,
Password: password + "-wrong",
},
},
toAddrs: []string{to},
expectedAuthMeth: sasl.Login,
expectedErr: "incorrect password",
retryable: true,
},
{
name: "password from file",
authMechs: []string{sasl.Login},
cfg: codersdk.NotificationsEmailConfig{
Hello: hello,
From: from,
Auth: codersdk.NotificationsEmailAuthConfig{
Username: username,
PasswordFile: "smtptest/fixtures/password.txt",
},
},
toAddrs: []string{to},
expectedAuthMeth: sasl.Login,
},
/**
* PLAIN auth mechanism
*/
{
name: "PLAIN auth",
authMechs: []string{sasl.Plain},
cfg: codersdk.NotificationsEmailConfig{
Hello: hello,
From: from,
Auth: codersdk.NotificationsEmailAuthConfig{
Identity: identity,
Username: username,
Password: password,
},
},
toAddrs: []string{to},
expectedAuthMeth: sasl.Plain,
},
{
name: "PLAIN auth without identity",
authMechs: []string{sasl.Plain},
cfg: codersdk.NotificationsEmailConfig{
Hello: hello,
From: from,
Auth: codersdk.NotificationsEmailAuthConfig{
Identity: "",
Username: username,
Password: password,
},
},
toAddrs: []string{to},
expectedAuthMeth: sasl.Plain,
},
{
name: "PLAIN+LOGIN, choose PLAIN",
authMechs: []string{sasl.Login, sasl.Plain},
cfg: codersdk.NotificationsEmailConfig{
Hello: hello,
From: from,
Auth: codersdk.NotificationsEmailAuthConfig{
Identity: identity,
Username: username,
Password: password,
},
},
toAddrs: []string{to},
expectedAuthMeth: sasl.Plain,
},
/**
* No auth mechanism
*/
{
name: "No auth mechanisms supported",
authMechs: []string{},
cfg: codersdk.NotificationsEmailConfig{
Hello: hello,
From: from,
Auth: codersdk.NotificationsEmailAuthConfig{
Username: username,
Password: password,
},
},
toAddrs: []string{to},
expectedAuthMeth: "",
expectedErr: "no authentication mechanisms supported by server",
retryable: false,
},
{
name: "No auth mechanisms supported, none configured",
authMechs: []string{},
cfg: codersdk.NotificationsEmailConfig{
Hello: hello,
From: from,
},
toAddrs: []string{to},
expectedAuthMeth: "",
},
{
name: "Auth mechanisms supported optionally, none configured",
authMechs: []string{sasl.Login, sasl.Plain},
cfg: codersdk.NotificationsEmailConfig{
Hello: hello,
From: from,
},
toAddrs: []string{to},
expectedAuthMeth: "",
},
/**
* TLS connections
*/
{
// TLS is forced but certificate used by mock server is untrusted.
name: "TLS: x509 untrusted",
useTLS: true,
expectedErr: "tls: failed to verify certificate",
retryable: true,
},
{
// TLS is forced and self-signed certificate used by mock server is not verified.
name: "TLS: x509 untrusted ignored",
useTLS: true,
cfg: codersdk.NotificationsEmailConfig{
Hello: hello,
From: from,
ForceTLS: true,
TLS: codersdk.NotificationsEmailTLSConfig{
InsecureSkipVerify: true,
},
},
toAddrs: []string{to},
},
{
// TLS is forced and STARTTLS is configured, but STARTTLS cannot be used by TLS connections.
// STARTTLS should be disabled and connection should succeed.
name: "TLS: STARTTLS is ignored",
useTLS: true,
cfg: codersdk.NotificationsEmailConfig{
Hello: hello,
From: from,
TLS: codersdk.NotificationsEmailTLSConfig{
InsecureSkipVerify: true,
StartTLS: true,
},
},
toAddrs: []string{to},
},
{
// Plain connection is established and upgraded via STARTTLS, but certificate is untrusted.
name: "TLS: STARTTLS untrusted",
useTLS: false,
cfg: codersdk.NotificationsEmailConfig{
TLS: codersdk.NotificationsEmailTLSConfig{
InsecureSkipVerify: false,
StartTLS: true,
},
ForceTLS: false,
},
expectedErr: "tls: failed to verify certificate",
retryable: true,
},
{
// Plain connection is established and upgraded via STARTTLS, certificate is not verified.
name: "TLS: STARTTLS",
useTLS: false,
cfg: codersdk.NotificationsEmailConfig{
Hello: hello,
From: from,
TLS: codersdk.NotificationsEmailTLSConfig{
InsecureSkipVerify: true,
StartTLS: true,
},
ForceTLS: false,
},
toAddrs: []string{to},
},
{
// TLS connection using self-signed certificate.
name: "TLS: self-signed",
useTLS: true,
cfg: codersdk.NotificationsEmailConfig{
Hello: hello,
From: from,
TLS: codersdk.NotificationsEmailTLSConfig{
CAFile: caFile,
CertFile: certFile,
KeyFile: keyFile,
},
},
toAddrs: []string{to},
},
{
// TLS connection using self-signed certificate & specifying the DNS name configured in the certificate.
name: "TLS: self-signed + SNI",
useTLS: true,
cfg: codersdk.NotificationsEmailConfig{
Hello: hello,
From: from,
TLS: codersdk.NotificationsEmailTLSConfig{
ServerName: "myserver.local",
CAFile: caFile,
CertFile: certFile,
KeyFile: keyFile,
},
},
toAddrs: []string{to},
},
{
name: "TLS: load CA",
useTLS: true,
cfg: codersdk.NotificationsEmailConfig{
TLS: codersdk.NotificationsEmailTLSConfig{
CAFile: "nope.crt",
},
},
// not using full error message here since it differs on *nix and Windows:
// *nix: no such file or directory
// Windows: The system cannot find the file specified.
expectedErr: "open nope.crt:",
retryable: true,
},
{
name: "TLS: load cert",
useTLS: true,
cfg: codersdk.NotificationsEmailConfig{
TLS: codersdk.NotificationsEmailTLSConfig{
CAFile: caFile,
CertFile: "smtptest/fixtures/nope.cert",
KeyFile: keyFile,
},
},
// not using full error message here since it differs on *nix and Windows:
// *nix: no such file or directory
// Windows: The system cannot find the file specified.
expectedErr: "open smtptest/fixtures/nope.cert:",
retryable: true,
},
{
name: "TLS: load cert key",
useTLS: true,
cfg: codersdk.NotificationsEmailConfig{
TLS: codersdk.NotificationsEmailTLSConfig{
CAFile: caFile,
CertFile: certFile,
KeyFile: "smtptest/fixtures/nope.key",
},
},
// not using full error message here since it differs on *nix and Windows:
// *nix: no such file or directory
// Windows: The system cannot find the file specified.
expectedErr: "open smtptest/fixtures/nope.key:",
retryable: true,
},
/**
* Kitchen sink
*/
{
name: "PLAIN auth and TLS",
useTLS: true,
authMechs: []string{sasl.Plain},
cfg: codersdk.NotificationsEmailConfig{
Hello: hello,
From: from,
Auth: codersdk.NotificationsEmailAuthConfig{
Identity: identity,
Username: username,
Password: password,
},
TLS: codersdk.NotificationsEmailTLSConfig{
CAFile: caFile,
CertFile: certFile,
KeyFile: keyFile,
},
},
toAddrs: []string{to},
expectedAuthMeth: sasl.Plain,
},
/**
* Other errors
*/
{
name: "Rejected on DATA",
cfg: codersdk.NotificationsEmailConfig{
Hello: hello,
From: from,
},
failOnDataFn: func() error {
return &smtp.SMTPError{Code: 501, EnhancedCode: smtp.EnhancedCode{5, 5, 4}, Message: "Rejected!"}
},
expectedErr: "SMTP error 501: Rejected!",
retryable: true,
},
}
// nolint:paralleltest // Reinitialization is not required as of Go v1.22.
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
tc.cfg.ForceTLS = serpent.Bool(tc.useTLS)
backend := smtptest.NewBackend(smtptest.Config{
AuthMechanisms: tc.authMechs,
AcceptedIdentity: tc.cfg.Auth.Identity.String(),
AcceptedUsername: username,
AcceptedPassword: password,
FailOnDataFn: tc.failOnDataFn,
})
// Create a mock SMTP server which conditionally listens for plain or TLS connections.
srv, listen, err := smtptest.CreateMockSMTPServer(backend, tc.useTLS)
require.NoError(t, err)
t.Cleanup(func() {
// We expect that the server has already been closed in the test
assert.ErrorIs(t, srv.Shutdown(ctx), smtp.ErrServerClosed)
})
errs := bytes.NewBuffer(nil)
srv.ErrorLog = log.New(errs, "oops", 0)
// Enable this to debug mock SMTP server.
// srv.Debug = os.Stderr
var hp serpent.HostPort
require.NoError(t, hp.Set(listen.Addr().String()))
tc.cfg.Smarthost = serpent.String(hp.String())
handler := dispatch.NewSMTPHandler(tc.cfg, logger.Named("smtp"))
// Start mock SMTP server in the background.
var wg sync.WaitGroup
wg.Go(func() {
assert.NoError(t, srv.Serve(listen))
})
// Wait for the server to become pingable.
require.Eventually(t, func() bool {
cl, err := smtptest.PingClient(listen, tc.useTLS, tc.cfg.TLS.StartTLS.Value())
if err != nil {
t.Logf("smtp not yet dialable: %s", err)
return false
}
if err = cl.Noop(); err != nil {
t.Logf("smtp not yet noopable: %s", err)
return false
}
if err = cl.Close(); err != nil {
t.Logf("smtp didn't close properly: %s", err)
return false
}
return true
}, testutil.WaitShort, testutil.IntervalFast)
// Build a fake payload.
payload := types.MessagePayload{
Version: "1.0",
UserEmail: to,
Labels: make(map[string]string),
}
dispatchFn, err := handler.Dispatcher(payload, subject, body, helpers())
require.NoError(t, err)
msgID := uuid.New()
retryable, err := dispatchFn(ctx, msgID)
if tc.expectedErr == "" {
require.Nil(t, err)
require.Empty(t, errs.Bytes())
msg := backend.LastMessage()
require.NotNil(t, msg)
backend.Reset()
require.Equal(t, tc.expectedAuthMeth, msg.AuthMech)
require.Equal(t, from, msg.From)
require.Equal(t, tc.toAddrs, msg.To)
if !tc.cfg.Auth.Empty() {
require.Equal(t, tc.cfg.Auth.Identity.String(), msg.Identity)
require.Equal(t, username, msg.Username)
require.Equal(t, password, msg.Password)
}
require.Contains(t, msg.Contents, subject)
require.Contains(t, msg.Contents, body)
require.Contains(t, msg.Contents, fmt.Sprintf("Message-Id: %s", msgID))
} else {
require.ErrorContains(t, err, tc.expectedErr)
}
require.Equal(t, tc.retryable, retryable)
require.NoError(t, srv.Shutdown(ctx))
wg.Wait()
})
}
}
// TestSMTPEnvelopeAndHeaders verifies that SMTP envelope addresses (used in
// MAIL FROM and RCPT TO commands) contain only bare email addresses, while
// email headers preserve the full address including display names.
//
// This is important because RFC 5321 requires envelope addresses to be bare
// emails, while RFC 5322 allows headers to include display names.
//
// See: https://github.com/coder/coder/issues/20727
func TestSMTPEnvelopeAndHeaders(t *testing.T) {
t.Parallel()
const (
hello = "localhost"
to = "bob@bob.com"
subject = "This is the subject"
body = "This is the body"
)
tests := []struct {
name string
fromConfig string // The configured From address (may include display name)
expectedEnvFrom string // Expected envelope MAIL FROM (bare email)
expectedHeaderFrom string // Expected From header (preserves display name)
}{
{
name: "bare email address",
fromConfig: "system@coder.com",
expectedEnvFrom: "system@coder.com",
expectedHeaderFrom: "system@coder.com",
},
{
name: "email with display name",
fromConfig: "Coder System <system@coder.com>",
expectedEnvFrom: "system@coder.com",
expectedHeaderFrom: "Coder System <system@coder.com>",
},
{
name: "email with quoted display name",
fromConfig: `"Coder Notifications" <notifications@coder.com>`,
expectedEnvFrom: "notifications@coder.com",
expectedHeaderFrom: `"Coder Notifications" <notifications@coder.com>`,
},
}
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(tc.fromConfig),
}
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, subject, body, helpers())
require.NoError(t, err)
msgID := uuid.New()
retryable, err := dispatchFn(ctx, msgID)
require.NoError(t, err)
require.False(t, retryable)
msg := backend.LastMessage()
require.NotNil(t, msg)
// Verify envelope address (MAIL FROM) contains only the bare email.
require.Equal(t, tc.expectedEnvFrom, msg.From,
"SMTP envelope MAIL FROM should contain only the bare email address")
// Verify header From preserves the display name.
require.Contains(t, msg.Contents, fmt.Sprintf("From: %s\r\n", tc.expectedHeaderFrom),
"Email From header should preserve the display name if present")
require.NoError(t, srv.Shutdown(ctx))
wg.Wait()
})
}
}
// 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()
})
}
}