-
Notifications
You must be signed in to change notification settings - Fork 64
Commit
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 #47 for more details and discussion.
- Loading branch information
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -3,6 +3,7 @@ | |
package normalize | ||
|
||
import ( | ||
"bytes" | ||
"strings" | ||
|
||
"blitiri.com.ar/go/chasquid/internal/envelope" | ||
|
@@ -72,3 +73,23 @@ func DomainToUnicode(addr string) (string, error) { | |
domain, err := Domain(domain) | ||
return user + "@" + domain, err | ||
} | ||
|
||
// ToCRLF converts the given buffer to CRLF line endings. If a line has a | ||
// preexisting CRLF, it leaves it be. It assumes that CR is never used on its | ||
// own. | ||
func ToCRLF(in []byte) []byte { | ||
b := bytes.NewBuffer(nil) | ||
b.Grow(len(in)) | ||
for _, c := range in { | ||
switch c { | ||
case '\r': | ||
This comment has been minimized.
Sorry, something went wrong.
This comment has been minimized.
Sorry, something went wrong.
albertito
Author
Owner
|
||
// Ignore CR, we'll add it back later. It should never appear | ||
// alone in the contexts where this is function is used. | ||
This comment has been minimized.
Sorry, something went wrong.
This comment has been minimized.
Sorry, something went wrong. |
||
case '\n': | ||
b.Write([]byte("\r\n")) | ||
default: | ||
b.WriteByte(c) | ||
} | ||
} | ||
return b.Bytes() | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -11,7 +11,6 @@ import ( | |
"math/rand" | ||
"net" | ||
"net/mail" | ||
"net/textproto" | ||
"os" | ||
"os/exec" | ||
"strconv" | ||
|
@@ -312,6 +311,9 @@ loop: | |
if err != nil { | ||
break | ||
} | ||
} else if code < 0 { | ||
// Negative code means that we have to break the connection. | ||
break | ||
} | ||
} | ||
|
||
|
@@ -638,19 +640,19 @@ func (c *Conn) DATA(params string) (code int, msg string) { | |
// one, we don't want the command timeout to interfere. | ||
c.conn.SetDeadline(c.deadline) | ||
|
||
// Create a dot reader, limited to the maximum size. | ||
dotr := textproto.NewReader(bufio.NewReader( | ||
io.LimitReader(c.reader, c.maxDataSize))).DotReader() | ||
c.data, err = io.ReadAll(dotr) | ||
// Read the data. Enforce CRLF correctness, and maximum size. | ||
c.data, err = readUntilDot(c.reader, int(c.maxDataSize)) | ||
This comment has been minimized.
Sorry, something went wrong.
ThinkChaos
Contributor
|
||
if err != nil { | ||
if err == io.ErrUnexpectedEOF { | ||
// Message is too big already. But we need to keep reading until we see | ||
// the "\r\n.\r\n", otherwise we will treat the remanent data that | ||
// the user keeps sending as commands, and that's a security | ||
// issue. | ||
readUntilDot(c.reader) | ||
if err == errMessageTooLarge { | ||
// Message is too big; excess data has already been discarded. | ||
return 552, "5.3.4 Message too big" | ||
} | ||
if err == errInvalidLineEnding { | ||
// We can't properly recover from this, so we have to drop the | ||
// connection. | ||
c.writeResponse(521, "5.5.2 Error reading DATA: invalid line ending") | ||
return -1, "Invalid line ending, closing connection" | ||
} | ||
return 554, fmt.Sprintf("5.4.0 Error reading DATA: %v", err) | ||
} | ||
|
||
|
@@ -952,24 +954,6 @@ func boolToStr(b bool) string { | |
return "0" | ||
} | ||
|
||
func readUntilDot(r *bufio.Reader) { | ||
prevMore := false | ||
for { | ||
// The reader will not read more than the size of the buffer, | ||
// so this doesn't cause increased memory consumption. | ||
// The reader's data deadline will prevent this from continuing | ||
// forever. | ||
l, more, err := r.ReadLine() | ||
if err != nil { | ||
break | ||
} | ||
if !more && !prevMore && string(l) == "." { | ||
break | ||
} | ||
prevMore = more | ||
} | ||
} | ||
|
||
// STARTTLS SMTP command handler. | ||
func (c *Conn) STARTTLS(params string) (code int, msg string) { | ||
if c.onTLS { | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,103 @@ | ||
package smtpsrv | ||
|
||
import ( | ||
"bufio" | ||
"bytes" | ||
"errors" | ||
"io" | ||
) | ||
|
||
var ( | ||
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 int) ([]byte, error) { | ||
buf := make([]byte, 0, 1024) | ||
n := 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 len(buf) < max && !skip { | ||
buf = append(buf, b) | ||
} | ||
skip = false | ||
} | ||
|
||
if n > max { | ||
return buf, errMessageTooLarge | ||
} | ||
This comment has been minimized.
Sorry, something went wrong.
This comment has been minimized.
Sorry, something went wrong.
albertito
Author
Owner
|
||
|
||
// 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 | ||
} |
Maybe this should return an error since that's never supposed to happen and means there's a bug in chasquid?