You must login to view /lordwelch/chasquid/commit/e79586a014dcabf3cdaae02b080ad7b96c94e018?files=chasquid.go.
The GitHub option should be usable for most people, it only links via username.

Files
chasquid/internal/smtpsrv/dotreader.go
Alberto Bertogli a996106eee smtpsrv: Strict CRLF enforcement in DATA contents
The RFCs are very clear that in DATA contents:

> CR and LF MUST only occur together as CRLF; they MUST NOT appear
> independently in the body.

https://www.rfc-editor.org/rfc/rfc5322#section-2.3
https://www.rfc-editor.org/rfc/rfc5321#section-2.3.8

Allowing "independent" CR and LF can cause a number of problems.

In particular, there is a new "SMTP smuggling attack" published recently
that involves the server incorrectly parsing the end of DATA marker
`\r\n.\r\n`, which an attacker can exploit to impersonate a server when
email is transmitted server-to-server.

https://www.postfix.org/smtp-smuggling.html
https://sec-consult.com/blog/detail/smtp-smuggling-spoofing-e-mails-worldwide/

Currently, chasquid is vulnerable to this attack, because Go's standard
libraries net/textproto and net/mail do not enforce CRLF strictly.

This patch fixes the problem by introducing a new "dot reader" function
that strictly enforces CRLF when reading dot-terminated data, used in
the DATA input processing.

When an invalid newline terminator is found, the connection is aborted
immediately because we cannot safely recover from that state.

We still keep the internal representation as LF-terminated for
convenience and simplicity.

However, the MDA courier is changed to pass CRLF-terminated lines, since
that is an external program which could be strict when receiving email
messages.

See https://github.com/albertito/chasquid/issues/47 for more details and
discussion.
2023-12-24 10:43:27 +00:00

112 lines
3.0 KiB
Go

package smtpsrv
import (
"bufio"
"bytes"
"errors"
"io"
)
var (
// TODO: Include the line number and specific error, and have the
// caller add them to the trace.
errMessageTooLarge = errors.New("message too large")
errInvalidLineEnding = errors.New("invalid line ending")
)
// readUntilDot reads from r until it encounters a dot-terminated line, or we
// read max bytes. It enforces that input lines are terminated by "\r\n", and
// that there are not "lonely" "\r" or "\n"s in the input.
// It returns \n-terminated lines, which is what we use for our internal
// representation for convenience (same as textproto DotReader does).
func readUntilDot(r *bufio.Reader, max int64) ([]byte, error) {
buf := make([]byte, 0, 1024)
n := int64(0)
// Little state machine.
const (
prevOther = iota
prevCR
prevCRLF
)
// Start as if we just came from a '\r\n'; that way we avoid the need
// for special-casing the dot-stuffing at the very beginning.
prev := prevCRLF
last4 := make([]byte, 4)
skip := false
loop:
for {
b, err := r.ReadByte()
if err == io.EOF {
return buf, io.ErrUnexpectedEOF
} else if err != nil {
return buf, err
}
n++
switch b {
case '\r':
if prev == prevCR {
return buf, errInvalidLineEnding
}
prev = prevCR
// We return a LF-terminated line, so skip the CR. This simplifies
// internal representation and makes it easier/less error prone to
// work with. It is converted back to CRLF on endpoints (e.g. in
// the couriers).
skip = true
case '\n':
if prev != prevCR {
return buf, errInvalidLineEnding
}
// If we come from a '\r\n.\r', we're done.
if bytes.Equal(last4, []byte("\r\n.\r")) {
break loop
}
// If we are only starting and see ".\r\n", we're also done; in
// that case the message is empty.
if n == 3 && bytes.Equal(last4, []byte("\x00\x00.\r")) {
return []byte{}, nil
}
prev = prevCRLF
default:
if prev == prevCR {
return buf, errInvalidLineEnding
}
if b == '.' && prev == prevCRLF {
// We come from "\r\n" and got a "."; as per dot-stuffing
// rules, we should skip that '.' in the output.
// https://www.rfc-editor.org/rfc/rfc5321#section-4.5.2
skip = true
}
prev = prevOther
}
// Keep the last 4 bytes separately, because they may not be in buf on
// messages that are too large.
copy(last4, last4[1:])
last4[3] = b
if int64(len(buf)) < max && !skip {
buf = append(buf, b)
}
skip = false
}
// Return an error if the message is too large. It is important to do this
// _outside_ the loop, because we need to keep reading until we get to the
// final "." before we return an error, so the SMTP dialog can continue
// properly after that.
// If we return too early, the remainder of the email is interpreted as
// part of the SMTP dialog (and exposing ourselves to smuggling attacks).
if n > max {
return buf, errMessageTooLarge
}
// If we made it this far, buf naturally ends in "\n" because we skipped
// the '.' due to dot-stuffing, and skip "\r"s.
return buf, nil
}