mirror of
https://blitiri.com.ar/repos/chasquid
synced 2025-12-17 14:37:02 +00:00
smtpsrv: Implement a post-DATA hook
This patch implements a post-DATA hook, which is run after receiving the data but before sending a reply. It can be used to implement content filtering when receiving email, for example for passing the email through an anti-spam or an anti-virus.
This commit is contained in:
@@ -62,6 +62,7 @@ func main() {
|
|||||||
s := smtpsrv.NewServer()
|
s := smtpsrv.NewServer()
|
||||||
s.Hostname = conf.Hostname
|
s.Hostname = conf.Hostname
|
||||||
s.MaxDataSize = conf.MaxDataSizeMb * 1024 * 1024
|
s.MaxDataSize = conf.MaxDataSizeMb * 1024 * 1024
|
||||||
|
s.PostDataHook = "hooks/post-data"
|
||||||
|
|
||||||
s.SetAliasesConfig(conf.SuffixSeparators, conf.DropCharacters)
|
s.SetAliasesConfig(conf.SuffixSeparators, conf.DropCharacters)
|
||||||
|
|
||||||
|
|||||||
35
hooks/post-data
Executable file
35
hooks/post-data
Executable file
@@ -0,0 +1,35 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
#
|
||||||
|
# This file is an example post-data hook that will run standard filtering
|
||||||
|
# utilities if they are available.
|
||||||
|
#
|
||||||
|
# - spamc (from Spamassassin) to filter spam.
|
||||||
|
# - clamdscan (from ClamAV) to filter virus.
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
TF="$(mktemp --tmpdir "post-data-XXXXXXXXXX")"
|
||||||
|
trap 'rm "$TF"' EXIT
|
||||||
|
|
||||||
|
# Save the message to the temporary file, so we can pass it on to the various
|
||||||
|
# filters.
|
||||||
|
cat > "$TF"
|
||||||
|
|
||||||
|
|
||||||
|
if command -v spamc >/dev/null; then
|
||||||
|
if ! SL=$(spamc -c - < "$TF") ; then
|
||||||
|
echo "spam detected"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "X-Spam-Score: $SL"
|
||||||
|
fi
|
||||||
|
|
||||||
|
|
||||||
|
if command -v clamdscan >/dev/null; then
|
||||||
|
if ! clamdscan --no-summary --infected - < "$TF" 1>&2 ; then
|
||||||
|
echo "virus detected"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "X-Virus-Scanned: pass"
|
||||||
|
fi
|
||||||
|
|
||||||
@@ -2,6 +2,7 @@ package smtpsrv
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"context"
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
"expvar"
|
"expvar"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -11,6 +12,8 @@ import (
|
|||||||
"net"
|
"net"
|
||||||
"net/mail"
|
"net/mail"
|
||||||
"net/textproto"
|
"net/textproto"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -36,6 +39,7 @@ var (
|
|||||||
loopsDetected = expvar.NewInt("chasquid/smtpIn/loopsDetected")
|
loopsDetected = expvar.NewInt("chasquid/smtpIn/loopsDetected")
|
||||||
tlsCount = expvar.NewMap("chasquid/smtpIn/tlsCount")
|
tlsCount = expvar.NewMap("chasquid/smtpIn/tlsCount")
|
||||||
slcResults = expvar.NewMap("chasquid/smtpIn/securityLevelChecks")
|
slcResults = expvar.NewMap("chasquid/smtpIn/securityLevelChecks")
|
||||||
|
hookResults = expvar.NewMap("chasquid/smtpIn/hookResults")
|
||||||
)
|
)
|
||||||
|
|
||||||
// Global event logs.
|
// Global event logs.
|
||||||
@@ -61,6 +65,9 @@ type Conn struct {
|
|||||||
// Maximum data size.
|
// Maximum data size.
|
||||||
maxDataSize int64
|
maxDataSize int64
|
||||||
|
|
||||||
|
// Post-DATA hook location.
|
||||||
|
postDataHook string
|
||||||
|
|
||||||
// Connection information.
|
// Connection information.
|
||||||
conn net.Conn
|
conn net.Conn
|
||||||
tc *textproto.Conn
|
tc *textproto.Conn
|
||||||
@@ -514,6 +521,12 @@ func (c *Conn) DATA(params string) (code int, msg string) {
|
|||||||
|
|
||||||
c.addReceivedHeader()
|
c.addReceivedHeader()
|
||||||
|
|
||||||
|
hookOut, err := c.runPostDataHook(c.data)
|
||||||
|
if err != nil {
|
||||||
|
return 554, err.Error()
|
||||||
|
}
|
||||||
|
c.data = append(hookOut, c.data...)
|
||||||
|
|
||||||
// There are no partial failures here: we put it in the queue, and then if
|
// There are no partial failures here: we put it in the queue, and then if
|
||||||
// individual deliveries fail, we report via email.
|
// individual deliveries fail, we report via email.
|
||||||
msgID, err := c.queue.Put(c.mailFrom, c.rcptTo, c.data)
|
msgID, err := c.queue.Put(c.mailFrom, c.rcptTo, c.data)
|
||||||
@@ -599,6 +612,115 @@ func checkData(data []byte) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// runPostDataHook and return the new headers to add, an error (if any), and
|
||||||
|
// true if the error is permanent or false if transient.
|
||||||
|
func (c *Conn) runPostDataHook(data []byte) ([]byte, error) {
|
||||||
|
// TODO: check if the file is executable.
|
||||||
|
if _, err := os.Stat(c.postDataHook); os.IsNotExist(err) {
|
||||||
|
hookResults.Add("post-data:skip", 1)
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
tr := trace.New("Hook.Post-DATA", c.conn.RemoteAddr().String())
|
||||||
|
defer tr.Finish()
|
||||||
|
tr.Debugf("running")
|
||||||
|
|
||||||
|
ctx, cancel := context.WithDeadline(context.Background(),
|
||||||
|
time.Now().Add(1*time.Minute))
|
||||||
|
defer cancel()
|
||||||
|
cmd := exec.CommandContext(ctx, c.postDataHook)
|
||||||
|
cmd.Stdin = bytes.NewReader(data)
|
||||||
|
|
||||||
|
// Prepare the environment, copying some common variables so the hook has
|
||||||
|
// someting reasonable, and then setting the specific ones for this case.
|
||||||
|
for _, v := range strings.Fields("USER PWD SHELL PATH") {
|
||||||
|
cmd.Env = append(cmd.Env, v+"="+os.Getenv(v))
|
||||||
|
}
|
||||||
|
cmd.Env = append(cmd.Env, "REMOTE_ADDR="+c.conn.RemoteAddr().String())
|
||||||
|
cmd.Env = append(cmd.Env, "MAIL_FROM="+c.mailFrom)
|
||||||
|
cmd.Env = append(cmd.Env, "RCPT_TO="+strings.Join(c.rcptTo, " "))
|
||||||
|
cmd.Env = append(cmd.Env, "AUTH_AS="+c.authUser+"@"+c.authDomain)
|
||||||
|
if c.onTLS {
|
||||||
|
cmd.Env = append(cmd.Env, "ON_TLS=1")
|
||||||
|
}
|
||||||
|
if envelope.DomainIn(c.mailFrom, c.localDomains) {
|
||||||
|
cmd.Env = append(cmd.Env, "FROM_LOCAL_DOMAIN=1")
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
hookResults.Add("post-data:fail", 1)
|
||||||
|
tr.Error(err)
|
||||||
|
tr.Debugf("stdout: %s", out)
|
||||||
|
if ee, ok := err.(*exec.ExitError); ok {
|
||||||
|
tr.Printf("stderr: %s", string(ee.Stderr))
|
||||||
|
}
|
||||||
|
|
||||||
|
// The error contains the last line of stdout, so filters can pass
|
||||||
|
// some rejection information back to the sender.
|
||||||
|
err = fmt.Errorf(lastLine(string(out)))
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check that output looks like headers, to avoid breaking the email
|
||||||
|
// contents. If it does not, just skip it.
|
||||||
|
if !isHeader(out) {
|
||||||
|
hookResults.Add("post-data:badoutput", 1)
|
||||||
|
tr.Errorf("error parsing post-data output: '%s'", out)
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
tr.Debugf("success")
|
||||||
|
tr.Debugf("stdout: %s", out)
|
||||||
|
hookResults.Add("post-data:success", 1)
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// isHeader checks if the given buffer is a valid MIME header.
|
||||||
|
func isHeader(b []byte) bool {
|
||||||
|
s := string(b)
|
||||||
|
if len(s) == 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// If it is just a \n, or contains two \n, then it's not a header.
|
||||||
|
if s == "\n" || strings.Contains(s, "\n\n") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// If it does not end in \n, not a header.
|
||||||
|
if s[len(s)-1] != '\n' {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Each line must either start with a space or have a ':'.
|
||||||
|
seen := false
|
||||||
|
for _, line := range strings.SplitAfter(s, "\n") {
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(line, " ") || strings.HasPrefix(line, "\t") {
|
||||||
|
if !seen {
|
||||||
|
// Continuation without a header first (invalid).
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !strings.Contains(line, ":") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
seen = true
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func lastLine(s string) string {
|
||||||
|
l := strings.Split(s, "\n")
|
||||||
|
if len(l) < 2 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return l[len(l)-2]
|
||||||
|
}
|
||||||
|
|
||||||
func (c *Conn) STARTTLS(params string) (code int, msg string) {
|
func (c *Conn) STARTTLS(params string) (code int, msg string) {
|
||||||
if c.onTLS {
|
if c.onTLS {
|
||||||
return 503, "You are already wearing that!"
|
return 503, "You are already wearing that!"
|
||||||
|
|||||||
@@ -58,3 +58,26 @@ func TestSecLevel(t *testing.T) {
|
|||||||
t.Fatalf("plain seclevel worked, downgrade was allowed")
|
t.Fatalf("plain seclevel worked, downgrade was allowed")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestIsHeader(t *testing.T) {
|
||||||
|
no := []string{
|
||||||
|
"a", "\n", "\n\n", " \n", " ",
|
||||||
|
"a:b", "a: b\nx: y",
|
||||||
|
"\na:b\n", " a\nb:c\n",
|
||||||
|
}
|
||||||
|
for _, s := range no {
|
||||||
|
if isHeader([]byte(s)) {
|
||||||
|
t.Errorf("%q accepted as header, should be rejected", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
yes := []string{
|
||||||
|
"", "a:b\n",
|
||||||
|
"X-Post-Data: success\n",
|
||||||
|
}
|
||||||
|
for _, s := range yes {
|
||||||
|
if !isHeader([]byte(s)) {
|
||||||
|
t.Errorf("%q rejected as header, should be accepted", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -53,6 +53,9 @@ type Server struct {
|
|||||||
|
|
||||||
// Queue where we put incoming mail.
|
// Queue where we put incoming mail.
|
||||||
queue *queue.Queue
|
queue *queue.Queue
|
||||||
|
|
||||||
|
// Path to the Post-DATA hook.
|
||||||
|
PostDataHook string
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewServer() *Server {
|
func NewServer() *Server {
|
||||||
@@ -193,6 +196,7 @@ func (s *Server) serve(l net.Listener, mode SocketMode) {
|
|||||||
sc := &Conn{
|
sc := &Conn{
|
||||||
hostname: s.Hostname,
|
hostname: s.Hostname,
|
||||||
maxDataSize: s.MaxDataSize,
|
maxDataSize: s.MaxDataSize,
|
||||||
|
postDataHook: s.PostDataHook,
|
||||||
conn: conn,
|
conn: conn,
|
||||||
tc: textproto.NewConn(conn),
|
tc: textproto.NewConn(conn),
|
||||||
mode: mode,
|
mode: mode,
|
||||||
|
|||||||
1
test/t-10-hooks/.gitignore
vendored
Normal file
1
test/t-10-hooks/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
config/hooks/post-data
|
||||||
8
test/t-10-hooks/config/chasquid.conf
Normal file
8
test/t-10-hooks/config/chasquid.conf
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
smtp_address: ":1025"
|
||||||
|
submission_address: ":1587"
|
||||||
|
monitoring_address: ":1099"
|
||||||
|
|
||||||
|
mail_delivery_agent_bin: "test-mda"
|
||||||
|
mail_delivery_agent_args: "%to%"
|
||||||
|
|
||||||
|
data_dir: "../.data"
|
||||||
5
test/t-10-hooks/config/hooks/post-data.bad1
Executable file
5
test/t-10-hooks/config/hooks/post-data.bad1
Executable file
@@ -0,0 +1,5 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
echo $0 > ../.data/post-data.out
|
||||||
|
echo "This is not a header"
|
||||||
|
|
||||||
8
test/t-10-hooks/config/hooks/post-data.bad2
Executable file
8
test/t-10-hooks/config/hooks/post-data.bad2
Executable file
@@ -0,0 +1,8 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
echo $0 > ../.data/post-data.out
|
||||||
|
|
||||||
|
echo "X-Post-DATA: This starts like a header"
|
||||||
|
echo
|
||||||
|
echo "But then is not"
|
||||||
|
|
||||||
7
test/t-10-hooks/config/hooks/post-data.bad3
Executable file
7
test/t-10-hooks/config/hooks/post-data.bad3
Executable file
@@ -0,0 +1,7 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
echo $0 > ../.data/post-data.out
|
||||||
|
|
||||||
|
# Just a newline is quite problematic, as it would break the headers.
|
||||||
|
echo
|
||||||
|
|
||||||
5
test/t-10-hooks/config/hooks/post-data.bad4
Executable file
5
test/t-10-hooks/config/hooks/post-data.bad4
Executable file
@@ -0,0 +1,5 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
echo $0 > ../.data/post-data.out
|
||||||
|
|
||||||
|
echo -n "X-Post-DATA: valid header with no newline at the end"
|
||||||
14
test/t-10-hooks/config/hooks/post-data.good
Executable file
14
test/t-10-hooks/config/hooks/post-data.good
Executable file
@@ -0,0 +1,14 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
env > ../.data/post-data.out
|
||||||
|
echo >> ../.data/post-data.out
|
||||||
|
|
||||||
|
cat >> ../.data/post-data.out
|
||||||
|
|
||||||
|
if [ "$RCPT_TO" == "blockme@testserver" ]; then
|
||||||
|
echo "¡No pasarán!"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "X-Post-Data: success"
|
||||||
|
|
||||||
4
test/t-10-hooks/content
Normal file
4
test/t-10-hooks/content
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
Subject: Prueba desde el test
|
||||||
|
|
||||||
|
Crece desde el test el futuro
|
||||||
|
Crece desde el test
|
||||||
1
test/t-10-hooks/hosts
Normal file
1
test/t-10-hooks/hosts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
testserver localhost
|
||||||
14
test/t-10-hooks/msmtprc
Normal file
14
test/t-10-hooks/msmtprc
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
account default
|
||||||
|
|
||||||
|
host testserver
|
||||||
|
port 1587
|
||||||
|
|
||||||
|
tls on
|
||||||
|
tls_trust_file config/certs/testserver/fullchain.pem
|
||||||
|
|
||||||
|
from user@testserver
|
||||||
|
|
||||||
|
auth on
|
||||||
|
user user@testserver
|
||||||
|
password secretpassword
|
||||||
|
|
||||||
64
test/t-10-hooks/run.sh
Executable file
64
test/t-10-hooks/run.sh
Executable file
@@ -0,0 +1,64 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
set -e
|
||||||
|
. $(dirname ${0})/../util/lib.sh
|
||||||
|
|
||||||
|
init
|
||||||
|
|
||||||
|
generate_certs_for testserver
|
||||||
|
add_user testserver user secretpassword
|
||||||
|
add_user testserver someone secretpassword
|
||||||
|
add_user testserver blockme secretpassword
|
||||||
|
|
||||||
|
mkdir -p .logs
|
||||||
|
chasquid -v=2 --log_dir=.logs --config_dir=config &
|
||||||
|
wait_until_ready 1025
|
||||||
|
|
||||||
|
cp config/hooks/post-data.good config/hooks/post-data
|
||||||
|
|
||||||
|
run_msmtp someone@testserver < content
|
||||||
|
|
||||||
|
wait_for_file .mail/someone@testserver
|
||||||
|
|
||||||
|
mail_diff content .mail/someone@testserver
|
||||||
|
|
||||||
|
if ! grep -q "X-Post-Data: success" .mail/someone@testserver; then
|
||||||
|
echo "missing X-Post-Data header"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
function check() {
|
||||||
|
if ! grep -q "$1" .data/post-data.out; then
|
||||||
|
echo missing: $1
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Verify that the environment for the hook was reasonable.
|
||||||
|
check "RCPT_TO=someone@testserver"
|
||||||
|
check "MAIL_FROM=user@testserver"
|
||||||
|
check "USER=$USER"
|
||||||
|
check "PWD=$PWD/config"
|
||||||
|
check "FROM_LOCAL_DOMAIN=1"
|
||||||
|
check "ON_TLS=1"
|
||||||
|
check "AUTH_AS=user@testserver"
|
||||||
|
check "PATH="
|
||||||
|
check "REMOTE_ADDR="
|
||||||
|
|
||||||
|
|
||||||
|
# Check that a failure in the script results in failing delivery.
|
||||||
|
if run_msmtp blockme@testserver < content 2>/dev/null; then
|
||||||
|
echo "ERROR: hook did not block email as expected"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check that the bad hooks don't prevent delivery.
|
||||||
|
for i in config/hooks/post-data.bad*; do
|
||||||
|
cp $i config/hooks/post-data
|
||||||
|
|
||||||
|
run_msmtp someone@testserver < content
|
||||||
|
wait_for_file .mail/someone@testserver
|
||||||
|
mail_diff content .mail/someone@testserver
|
||||||
|
done
|
||||||
|
|
||||||
|
success
|
||||||
Reference in New Issue
Block a user