mirror of
https://github.com/coder/coder.git
synced 2026-10-31 02:43:21 +08:00
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.
219 lines
6.5 KiB
Go
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)
|
|
})
|
|
}
|
|
}
|