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

Skip to content

Commit 5f4cc5e

Browse files
github-actions[bot]BobbyHoclaude
authored
fix: prevent markdown injection in notifications (#28340) (#28606)
Backport of #28340 Original PR: #28340 — fix: prevent markdown injection in notifications Merge commit: 9e8075d Requested by: @BobbyHo --------- Co-authored-by: Bobby Ho <[email protected]> Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
1 parent 904d4a7 commit 5f4cc5e

13 files changed

Lines changed: 1490 additions & 7 deletions

File tree

coderd/notifications/dispatch/smtp.go

Lines changed: 71 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,9 @@ import (
66
"crypto/tls"
77
"crypto/x509"
88
_ "embed"
9+
"encoding/base64"
910
"fmt"
11+
"mime"
1012
"mime/multipart"
1113
"mime/quotedprintable"
1214
"net"
@@ -18,6 +20,7 @@ import (
1820
"sync"
1921
"text/template"
2022
"time"
23+
"unicode/utf8"
2124

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

69-
htmlBody := markdown.HTMLFromMarkdown(bodyTmpl)
72+
htmlBody := markdown.HTMLFromNotificationMarkdown(bodyTmpl)
7073
plainBody, err := markdown.PlaintextFromMarkdown(bodyTmpl)
7174
if err != nil {
7275
return nil, xerrors.Errorf("render plaintext body: %w", err)
@@ -202,7 +205,7 @@ func (s *SMTPHandler) dispatch(subject, htmlBody, plainBody, to string) Delivery
202205
multipartWriter := multipart.NewWriter(multipartBuffer)
203206
_, _ = fmt.Fprintf(msg, "From: %s\r\n", headerFrom)
204207
_, _ = fmt.Fprintf(msg, "To: %s\r\n", strings.Join(recipients, ", "))
205-
_, _ = fmt.Fprintf(msg, "Subject: %s\r\n", subject)
208+
_, _ = fmt.Fprintf(msg, "Subject: %s\r\n", encodeHeaderValue(subject))
206209
_, _ = fmt.Fprintf(msg, "Message-Id: %s@%s\r\n", msgID, s.hostname())
207210
_, _ = fmt.Fprintf(msg, "Date: %s\r\n", time.Now().Format(time.RFC1123Z))
208211
_, _ = fmt.Fprintf(msg, "Content-Type: multipart/alternative; boundary=%s\r\n", multipartWriter.Boundary())
@@ -573,3 +576,69 @@ func (s *SMTPHandler) password() (string, error) {
573576
}
574577
return s.cfg.Auth.Password.String(), nil
575578
}
579+
580+
const (
581+
encodedWordPrefix = "=?utf-8?b?"
582+
encodedWordSuffix = "?="
583+
// RFC 2047 limits an encoded-word to 75 characters including its
584+
// delimiters, and base64 expands three bytes to four characters.
585+
encodedWordMaxBytes = (75 - len(encodedWordPrefix) - len(encodedWordSuffix)) / 4 * 3
586+
587+
// maxHeaderValueOctets is the longest value emitted unfolded. RFC 5322 caps
588+
// a line at 998 octets; the rest of the budget covers the field name.
589+
maxHeaderValueOctets = 900
590+
)
591+
592+
// encodeHeaderValue prepares a rendered value for use as a header value. Line
593+
// breaks become spaces so the value cannot terminate the header and inject
594+
// another.
595+
func encodeHeaderValue(value string) string {
596+
if strings.ContainsAny(value, "\r\n") {
597+
value = strings.Map(func(r rune) rune {
598+
if r == '\r' || r == '\n' {
599+
return ' '
600+
}
601+
return r
602+
}, value)
603+
}
604+
// A forged encoded-word is printable ASCII, which mime.WordEncoder passes
605+
// through untouched for the recipient's client to decode.
606+
if strings.Contains(value, "=?") {
607+
return encodeWords(value)
608+
}
609+
// Length is measured on the encoded form: Q-encoding expands a non-ASCII
610+
// rune to three characters per byte, so a short value can still exceed the
611+
// line limit. WordEncoder separates words with a space rather than folding,
612+
// so anything over the limit goes to encodeWords.
613+
if encoded := mime.QEncoding.Encode("utf-8", value); len(encoded) <= maxHeaderValueOctets {
614+
return encoded
615+
}
616+
return encodeWords(value)
617+
}
618+
619+
// encodeWords emits value as RFC 2047 base64 encoded-words, joined with CRLF
620+
// and a space so they both concatenate per RFC 2047 and fold per RFC 5322.
621+
func encodeWords(value string) string {
622+
var words []string
623+
for len(value) > 0 {
624+
n := encodedWordMaxBytes
625+
if n >= len(value) {
626+
n = len(value)
627+
} else {
628+
// Each encoded-word must decode on its own, so a multi-byte rune
629+
// cannot straddle two of them.
630+
for n > 0 && !utf8.RuneStart(value[n]) {
631+
n--
632+
}
633+
if n == 0 {
634+
// A rune wider than the budget: emit it whole rather than
635+
// splitting it into something undecodable.
636+
_, n = utf8.DecodeRuneInString(value)
637+
}
638+
}
639+
words = append(words, encodedWordPrefix+
640+
base64.StdEncoding.EncodeToString([]byte(value[:n]))+encodedWordSuffix)
641+
value = value[n:]
642+
}
643+
return strings.Join(words, "\r\n ")
644+
}

coderd/notifications/dispatch/smtp_internal_test.go

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package dispatch
22

33
import (
44
"html"
5+
"mime"
56
"strings"
67
"testing"
78

@@ -116,3 +117,102 @@ func TestValidateFromAddr(t *testing.T) {
116117
})
117118
}
118119
}
120+
121+
func TestEncodeHeaderValue(t *testing.T) {
122+
t.Parallel()
123+
124+
tests := []struct {
125+
name string
126+
value string
127+
want string
128+
}{
129+
{
130+
name: "ascii is unchanged",
131+
value: `User account "bobby" suspended`,
132+
want: `User account "bobby" suspended`,
133+
},
134+
{
135+
name: "crlf is folded",
136+
value: "Subject\r\nBcc: [email protected]",
137+
want: "Subject Bcc: [email protected]",
138+
},
139+
{
140+
name: "bare newline is folded",
141+
value: "Subject\nBcc: [email protected]",
142+
want: "Subject Bcc: [email protected]",
143+
},
144+
{
145+
name: "non-ascii is q-encoded",
146+
value: "Konto gelöscht",
147+
want: "=?utf-8?q?Konto_gel=C3=B6scht?=",
148+
},
149+
}
150+
151+
for _, tc := range tests {
152+
t.Run(tc.name, func(t *testing.T) {
153+
t.Parallel()
154+
155+
got := encodeHeaderValue(tc.value)
156+
require.Equal(t, tc.want, got)
157+
// The result must never be able to terminate its own header.
158+
require.NotContains(t, got, "\r")
159+
require.NotContains(t, got, "\n")
160+
})
161+
}
162+
}
163+
164+
// TestEncodeHeaderValueEncodedWord covers a forged RFC 2047 encoded-word, which
165+
// is printable ASCII and so passes mime.WordEncoder through to the client.
166+
func TestEncodeHeaderValueEncodedWord(t *testing.T) {
167+
t.Parallel()
168+
169+
// Decodes to "URGENT: verify your account".
170+
const forged = "=?utf-8?B?VVJHRU5UOiB2ZXJpZnkgeW91ciBhY2NvdW50?="
171+
got := encodeHeaderValue(forged + " shared a chat with you")
172+
173+
// The forged word must not survive as something a client would decode.
174+
require.NotContains(t, got, forged)
175+
176+
// Decoded rather than compared: chunk boundaries are an implementation detail.
177+
decoded, err := new(mime.WordDecoder).DecodeHeader(got)
178+
require.NoError(t, err)
179+
require.Equal(t, forged+" shared a chat with you", decoded)
180+
}
181+
182+
// TestEncodeHeaderValueFolds covers RFC 5322's 998-octet line limit, which
183+
// mime.WordEncoder does not fold for.
184+
func TestEncodeHeaderValueFolds(t *testing.T) {
185+
t.Parallel()
186+
187+
for name, value := range map[string]string{
188+
"non-ascii": strings.Repeat("é", 600),
189+
"ascii": strings.Repeat("a b ", 400),
190+
// A rune that does not divide evenly into the per-word budget must not
191+
// be split across two encoded-words: each has to decode on its own.
192+
"multibyte": strings.Repeat("日本語", 400),
193+
// Under the raw byte limit and over it once Q-encoded, so these fail
194+
// unless the gate measures the encoded form.
195+
"200 accented runes": strings.Repeat("é", 200),
196+
"300 cjk runes": strings.Repeat("日", 300),
197+
"200 emoji": strings.Repeat("🎉", 200),
198+
} {
199+
t.Run(name, func(t *testing.T) {
200+
t.Parallel()
201+
202+
got := encodeHeaderValue(value)
203+
for _, line := range strings.Split(got, "\r\n") {
204+
require.LessOrEqual(t, len(line), 998,
205+
"a header line exceeds RFC 5322's limit: %d octets", len(line))
206+
}
207+
// A CRLF must begin a continuation, or this is injection not folding.
208+
for _, after := range strings.Split(got, "\r\n")[1:] {
209+
require.True(t, strings.HasPrefix(after, " "),
210+
"a CRLF was not followed by folding whitespace: %q", got)
211+
}
212+
213+
decoded, err := new(mime.WordDecoder).DecodeHeader(got)
214+
require.NoError(t, err)
215+
require.Equal(t, value, decoded)
216+
})
217+
}
218+
}

coderd/notifications/dispatch/smtp_test.go

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"bytes"
55
"fmt"
66
"log"
7+
"strings"
78
"sync"
89
"testing"
910

@@ -636,3 +637,130 @@ func TestSMTPEnvelopeAndHeaders(t *testing.T) {
636637
})
637638
}
638639
}
640+
641+
// TestSMTPSubjectHeader: a rendered subject must not terminate the Subject
642+
// header, and a non-ASCII one must be RFC 2047 encoded rather than raw 8-bit.
643+
func TestSMTPSubjectHeader(t *testing.T) {
644+
t.Parallel()
645+
646+
const (
647+
hello = "localhost"
648+
649+
body = "This is the body"
650+
)
651+
652+
tests := []struct {
653+
name string
654+
// title is the rendered title template handed to the dispatcher.
655+
title string
656+
// wantSubject, when set, is the exact Subject header value.
657+
wantSubject string
658+
// wantSubjectContains are substrings the single Subject line must hold,
659+
// used where pinning exact output would test glamour, not the header.
660+
wantSubjectContains []string
661+
// wantAbsent must not appear anywhere in the transmitted message.
662+
wantAbsent string
663+
}{
664+
{
665+
name: "plain subject",
666+
title: "This is the subject",
667+
wantSubject: "This is the subject",
668+
},
669+
{
670+
name: "newline cannot inject a header",
671+
// PlaintextFromMarkdown keeps the paragraph break, so this reaches
672+
// the header writer with newlines in it.
673+
title: "Innocent subject\n\nBcc: [email protected]",
674+
wantSubjectContains: []string{"Innocent subject", "Bcc: [email protected]"},
675+
wantAbsent: "\r\nBcc:",
676+
},
677+
{
678+
name: "non-ascii subject is encoded",
679+
title: "Konto gelöscht",
680+
wantSubject: "=?utf-8?q?Konto_gel=C3=B6scht?=",
681+
},
682+
}
683+
684+
for _, tc := range tests {
685+
t.Run(tc.name, func(t *testing.T) {
686+
t.Parallel()
687+
688+
ctx := testutil.Context(t, testutil.WaitShort)
689+
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug)
690+
691+
cfg := codersdk.NotificationsEmailConfig{
692+
Hello: serpent.String(hello),
693+
From: serpent.String("[email protected]"),
694+
}
695+
696+
backend := smtptest.NewBackend(smtptest.Config{AuthMechanisms: []string{}})
697+
srv, listen, err := smtptest.CreateMockSMTPServer(backend, false)
698+
require.NoError(t, err)
699+
t.Cleanup(func() {
700+
assert.ErrorIs(t, srv.Shutdown(ctx), smtp.ErrServerClosed)
701+
})
702+
703+
var hp serpent.HostPort
704+
require.NoError(t, hp.Set(listen.Addr().String()))
705+
cfg.Smarthost = serpent.String(hp.String())
706+
707+
handler := dispatch.NewSMTPHandler(cfg, logger.Named("smtp"))
708+
709+
var wg sync.WaitGroup
710+
wg.Go(func() {
711+
assert.NoError(t, srv.Serve(listen))
712+
})
713+
714+
require.Eventually(t, func() bool {
715+
cl, err := smtptest.PingClient(listen, false, false)
716+
if err != nil {
717+
return false
718+
}
719+
_ = cl.Close()
720+
return true
721+
}, testutil.WaitShort, testutil.IntervalFast)
722+
723+
payload := types.MessagePayload{
724+
Version: "1.0",
725+
UserEmail: to,
726+
Labels: make(map[string]string),
727+
}
728+
729+
dispatchFn, err := handler.Dispatcher(payload, tc.title, body, helpers())
730+
require.NoError(t, err)
731+
732+
retryable, err := dispatchFn(ctx, uuid.New())
733+
require.NoError(t, err)
734+
require.False(t, retryable)
735+
736+
msg := backend.LastMessage()
737+
require.NotNil(t, msg)
738+
739+
// Assertions are scoped to the header block, which a blank line ends.
740+
headers, _, found := strings.Cut(msg.Contents, "\r\n\r\n")
741+
require.True(t, found, "message has no header/body separator")
742+
743+
// The header must occupy exactly one line, whatever the value held.
744+
require.Equal(t, 1, strings.Count(headers, "Subject: "),
745+
"exactly one Subject header must be present")
746+
_, after, found := strings.Cut(headers, "Subject: ")
747+
require.True(t, found, "no Subject header in %q", headers)
748+
subject, _, found := strings.Cut(after, "\r\n")
749+
require.True(t, found, "Subject header is not CRLF terminated")
750+
751+
if tc.wantSubject != "" {
752+
require.Equal(t, tc.wantSubject, subject)
753+
}
754+
for _, want := range tc.wantSubjectContains {
755+
require.Contains(t, subject, want)
756+
}
757+
if tc.wantAbsent != "" {
758+
require.NotContains(t, headers, tc.wantAbsent,
759+
"a value must not be able to inject an additional header")
760+
}
761+
762+
require.NoError(t, srv.Shutdown(ctx))
763+
wg.Wait()
764+
})
765+
}
766+
}

0 commit comments

Comments
 (0)