Files
coder/coderd/notifications/dispatch/smtp_internal_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

219 lines
6.5 KiB
Go

package dispatch
import (
"html"
"mime"
"strings"
"testing"
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/coderd/notifications/render"
"github.com/coder/coder/v2/coderd/notifications/types"
)
func TestSMTPHTMLTemplateEscapesAppearanceHelpers(t *testing.T) {
t.Parallel()
const (
appName = `Coder"><script>alert(1)</script>`
logoURL = `https://example.com/logo.png"><img src=x onerror=alert(1)>`
)
payload := types.MessagePayload{
NotificationTemplateID: "00000000-0000-0000-0000-000000000000",
UserName: "Test User",
Labels: map[string]string{
"_subject": "Test notification",
"_body": "<p>Test body</p>",
},
}
helpers := 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 },
}
got, err := render.GoTemplate(htmlTemplate, payload, helpers)
require.NoError(t, err)
require.True(t, strings.Contains(got, html.EscapeString(appName)), "application name must be HTML escaped")
require.True(t, strings.Contains(got, html.EscapeString(logoURL)), "logo URL must be HTML escaped")
require.False(t, strings.Contains(got, appName), "raw application name must not be rendered")
require.False(t, strings.Contains(got, logoURL), "raw logo URL must not be rendered")
}
func TestValidateFromAddr(t *testing.T) {
t.Parallel()
tests := []struct {
name string
input string
expectedEnvelope string
expectedHeader string
expectedErrContain string
}{
{
name: "bare email address",
input: "system@coder.com",
expectedEnvelope: "system@coder.com",
expectedHeader: "system@coder.com",
},
{
name: "email with display name",
input: "Coder System <system@coder.com>",
expectedEnvelope: "system@coder.com",
expectedHeader: "Coder System <system@coder.com>",
},
{
name: "email with quoted display name",
input: `"Coder Notifications" <notifications@coder.com>`,
expectedEnvelope: "notifications@coder.com",
expectedHeader: `"Coder Notifications" <notifications@coder.com>`,
},
{
name: "email with special characters in display name",
input: `"O'Brien, John" <john@example.com>`,
expectedEnvelope: "john@example.com",
expectedHeader: `"O'Brien, John" <john@example.com>`,
},
{
name: "invalid email address",
input: "not-an-email",
expectedErrContain: "parse 'from' address",
},
{
name: "empty string",
input: "",
expectedErrContain: "parse 'from' address",
},
{
name: "multiple addresses",
input: "a@example.com, b@example.com",
expectedErrContain: "'from' address not defined",
},
}
handler := &SMTPHandler{}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
envelope, header, err := handler.validateFromAddr(tc.input)
if tc.expectedErrContain != "" {
require.Error(t, err)
require.ErrorContains(t, err, tc.expectedErrContain)
return
}
require.NoError(t, err)
require.Equal(t, tc.expectedEnvelope, envelope,
"envelope address should be the bare email")
require.Equal(t, tc.expectedHeader, header,
"header address should preserve the original input")
})
}
}
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)
})
}
}