1
0
mirror of https://blitiri.com.ar/repos/chasquid synced 2025-12-17 14:37:02 +00:00
Files
go-chasquid-smtp/internal/courier/mda.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

111 lines
3.1 KiB
Go

package courier
import (
"bytes"
"context"
"fmt"
"os/exec"
"strings"
"syscall"
"time"
"unicode"
"blitiri.com.ar/go/chasquid/internal/envelope"
"blitiri.com.ar/go/chasquid/internal/normalize"
"blitiri.com.ar/go/chasquid/internal/trace"
)
var (
errTimeout = fmt.Errorf("operation timed out")
)
// MDA delivers local mail by executing a local binary, like procmail or
// maildrop. It works with any binary that:
// - Receives the email to deliver via stdin.
// - Exits with code EX_TEMPFAIL (75) for transient issues.
type MDA struct {
Binary string // Path to the binary.
Args []string // Arguments to pass.
Timeout time.Duration // Timeout for each invocation.
}
// Deliver an email. On failures, returns an error, and whether or not it is
// permanent.
func (p *MDA) Deliver(from string, to string, data []byte) (error, bool) {
tr := trace.New("Courier.MDA", to)
defer tr.Finish()
// Sanitize, just in case.
from = sanitizeForMDA(from)
to = sanitizeForMDA(to)
tr.Debugf("%s -> %s", from, to)
// Prepare the command, replacing the necessary arguments.
replacer := strings.NewReplacer(
"%from%", from,
"%from_user%", envelope.UserOf(from),
"%from_domain%", envelope.DomainOf(from),
"%to%", to,
"%to_user%", envelope.UserOf(to),
"%to_domain%", envelope.DomainOf(to),
)
args := []string{}
for _, a := range p.Args {
args = append(args, replacer.Replace(a))
}
tr.Debugf("%s %q", p.Binary, args)
ctx, cancel := context.WithTimeout(context.Background(), p.Timeout)
defer cancel()
cmd := exec.CommandContext(ctx, p.Binary, args...)
// Pass the email data via stdin. Normalize it to CRLF which is what the
// RFC-compliant representation require. By doing this at this end, we can
// keep a simpler internal representation and ensure there won't be any
// inconsistencies in newlines within the message (e.g. added headers).
cmd.Stdin = bytes.NewReader(normalize.ToCRLF(data))
output, err := cmd.CombinedOutput()
if ctx.Err() == context.DeadlineExceeded {
return tr.Error(errTimeout), false
}
if err != nil {
// Determine if the error is permanent or not.
// Default to permanent, but error code 75 is transient by general
// convention (/usr/include/sysexits.h), and commonly relied upon.
permanent := true
if exiterr, ok := err.(*exec.ExitError); ok {
if status, ok := exiterr.Sys().(syscall.WaitStatus); ok {
permanent = status.ExitStatus() != 75
}
}
err = tr.Errorf("MDA delivery failed: %v - %q", err, string(output))
return err, permanent
}
tr.Debugf("delivered")
return nil, false
}
// sanitizeForMDA cleans the string, removing characters that could be
// problematic considering we will run an external command.
//
// The server does not rely on this to do substitution or proper filtering,
// that's done at a different layer; this is just for defense in depth.
func sanitizeForMDA(s string) string {
valid := func(r rune) rune {
switch {
case unicode.IsSpace(r), unicode.IsControl(r),
strings.ContainsRune("/;\"'\\|*&$%()[]{}`!", r):
return rune(-1)
default:
return r
}
}
return strings.Map(valid, s)
}