Thanks to visit codestin.com
Credit goes to github.com

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 71 additions & 2 deletions coderd/notifications/dispatch/smtp.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@ import (
"crypto/tls"
"crypto/x509"
_ "embed"
"encoding/base64"
"fmt"
"mime"
"mime/multipart"
"mime/quotedprintable"
"net"
Expand All @@ -18,6 +20,7 @@ import (
"sync"
"text/template"
"time"
"unicode/utf8"

"github.com/emersion/go-sasl"
smtp "github.com/emersion/go-smtp"
Expand Down Expand Up @@ -66,7 +69,7 @@ func (s *SMTPHandler) Dispatcher(payload types.MessagePayload, titleTmpl, bodyTm
return nil, xerrors.Errorf("render subject: %w", err)
}

htmlBody := markdown.HTMLFromMarkdown(bodyTmpl)
htmlBody := markdown.HTMLFromNotificationMarkdown(bodyTmpl)
plainBody, err := markdown.PlaintextFromMarkdown(bodyTmpl)
if err != nil {
return nil, xerrors.Errorf("render plaintext body: %w", err)
Expand Down Expand Up @@ -202,7 +205,7 @@ func (s *SMTPHandler) dispatch(subject, htmlBody, plainBody, to string) Delivery
multipartWriter := multipart.NewWriter(multipartBuffer)
_, _ = fmt.Fprintf(msg, "From: %s\r\n", headerFrom)
_, _ = fmt.Fprintf(msg, "To: %s\r\n", strings.Join(recipients, ", "))
_, _ = fmt.Fprintf(msg, "Subject: %s\r\n", subject)
_, _ = fmt.Fprintf(msg, "Subject: %s\r\n", encodeHeaderValue(subject))
_, _ = fmt.Fprintf(msg, "Message-Id: %s@%s\r\n", msgID, s.hostname())
_, _ = fmt.Fprintf(msg, "Date: %s\r\n", time.Now().Format(time.RFC1123Z))
_, _ = fmt.Fprintf(msg, "Content-Type: multipart/alternative; boundary=%s\r\n", multipartWriter.Boundary())
Expand Down Expand Up @@ -573,3 +576,69 @@ func (s *SMTPHandler) password() (string, error) {
}
return s.cfg.Auth.Password.String(), nil
}

const (
encodedWordPrefix = "=?utf-8?b?"
encodedWordSuffix = "?="
// RFC 2047 limits an encoded-word to 75 characters including its
// delimiters, and base64 expands three bytes to four characters.
encodedWordMaxBytes = (75 - len(encodedWordPrefix) - len(encodedWordSuffix)) / 4 * 3

// maxHeaderValueOctets is the longest value emitted unfolded. RFC 5322 caps
// a line at 998 octets; the rest of the budget covers the field name.
maxHeaderValueOctets = 900
)

// encodeHeaderValue prepares a rendered value for use as a header value. Line
// breaks become spaces so the value cannot terminate the header and inject
// another.
func encodeHeaderValue(value string) string {
if strings.ContainsAny(value, "\r\n") {
value = strings.Map(func(r rune) rune {
if r == '\r' || r == '\n' {
return ' '
}
return r
}, value)
}
// A forged encoded-word is printable ASCII, which mime.WordEncoder passes
// through untouched for the recipient's client to decode.
if strings.Contains(value, "=?") {
return encodeWords(value)
}
// Length is measured on the encoded form: Q-encoding expands a non-ASCII
// rune to three characters per byte, so a short value can still exceed the
// line limit. WordEncoder separates words with a space rather than folding,
// so anything over the limit goes to encodeWords.
if encoded := mime.QEncoding.Encode("utf-8", value); len(encoded) <= maxHeaderValueOctets {
return encoded
}
return encodeWords(value)
}

// encodeWords emits value as RFC 2047 base64 encoded-words, joined with CRLF
// and a space so they both concatenate per RFC 2047 and fold per RFC 5322.
func encodeWords(value string) string {
var words []string
for len(value) > 0 {
n := encodedWordMaxBytes
if n >= len(value) {
n = len(value)
} else {
// Each encoded-word must decode on its own, so a multi-byte rune
// cannot straddle two of them.
for n > 0 && !utf8.RuneStart(value[n]) {
n--
}
if n == 0 {
// A rune wider than the budget: emit it whole rather than
// splitting it into something undecodable.
_, n = utf8.DecodeRuneInString(value)
}
}
words = append(words, encodedWordPrefix+
base64.StdEncoding.EncodeToString([]byte(value[:n]))+encodedWordSuffix)
value = value[n:]
}
return strings.Join(words, "\r\n ")
}
100 changes: 100 additions & 0 deletions coderd/notifications/dispatch/smtp_internal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package dispatch

import (
"html"
"mime"
"strings"
"testing"

Expand Down Expand Up @@ -116,3 +117,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: [email protected]",
want: "Subject Bcc: [email protected]",
},
{
name: "bare newline is folded",
value: "Subject\nBcc: [email protected]",
want: "Subject Bcc: [email protected]",
},
{
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)
})
}
}
128 changes: 128 additions & 0 deletions coderd/notifications/dispatch/smtp_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"bytes"
"fmt"
"log"
"strings"
"sync"
"testing"

Expand Down Expand Up @@ -636,3 +637,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 = "[email protected]"
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: [email protected]",
wantSubjectContains: []string{"Innocent subject", "Bcc: [email protected]"},
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("[email protected]"),
}

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()
})
}
}
Loading
Loading