maddy

Fork https://github.com/foxcpp/maddy

git clone git://git.lin.moe/go/maddy.git

  1/*
  2Maddy Mail Server - Composable all-in-one email server.
  3Copyright © 2019-2020 Max Mazurov <fox.cpp@disroot.org>, Maddy Mail Server contributors
  4
  5This program is free software: you can redistribute it and/or modify
  6it under the terms of the GNU General Public License as published by
  7the Free Software Foundation, either version 3 of the License, or
  8(at your option) any later version.
  9
 10This program is distributed in the hope that it will be useful,
 11but WITHOUT ANY WARRANTY; without even the implied warranty of
 12MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 13GNU General Public License for more details.
 14
 15You should have received a copy of the GNU General Public License
 16along with this program.  If not, see <https://www.gnu.org/licenses/>.
 17*/
 18
 19package msgpipeline
 20
 21import (
 22	"bufio"
 23	"context"
 24	"crypto/sha1"
 25	"encoding/hex"
 26	"errors"
 27	"net"
 28	"strings"
 29	"testing"
 30
 31	"github.com/emersion/go-message/textproto"
 32	"github.com/emersion/go-msgauth/authres"
 33	"github.com/emersion/go-smtp"
 34	"github.com/foxcpp/go-mockdns"
 35	"github.com/foxcpp/maddy/framework/buffer"
 36	"github.com/foxcpp/maddy/framework/exterrors"
 37	"github.com/foxcpp/maddy/framework/module"
 38	"github.com/foxcpp/maddy/internal/testutils"
 39)
 40
 41func doTestDelivery(t *testing.T, tgt module.DeliveryTarget, from string, to []string, hdr string) (string, error) {
 42	t.Helper()
 43
 44	IDRaw := sha1.Sum([]byte(t.Name()))
 45	encodedID := hex.EncodeToString(IDRaw[:])
 46
 47	body := buffer.MemoryBuffer{Slice: []byte("foobar")}
 48	ctx := module.MsgMetadata{
 49		DontTraceSender: true,
 50		ID:              encodedID,
 51	}
 52
 53	hdrParsed, err := textproto.ReadHeader(bufio.NewReader(strings.NewReader(hdr)))
 54	if err != nil {
 55		panic(err)
 56	}
 57
 58	delivery, err := tgt.Start(context.Background(), &ctx, from)
 59	if err != nil {
 60		return encodedID, err
 61	}
 62	for _, rcpt := range to {
 63		if err := delivery.AddRcpt(context.Background(), rcpt, smtp.RcptOptions{}); err != nil {
 64			if err := delivery.Abort(context.Background()); err != nil {
 65				t.Log("delivery.Abort:", err)
 66			}
 67			return encodedID, err
 68		}
 69	}
 70	if err := delivery.Body(context.Background(), hdrParsed, body); err != nil {
 71		if err := delivery.Abort(context.Background()); err != nil {
 72			t.Log("delivery.Abort:", err)
 73		}
 74		return encodedID, err
 75	}
 76	if err := delivery.Commit(context.Background()); err != nil {
 77		return encodedID, err
 78	}
 79
 80	return encodedID, err
 81}
 82
 83func dmarcResult(t *testing.T, hdr textproto.Header) authres.ResultValue {
 84	field := hdr.Get("Authentication-Results")
 85	if field == "" {
 86		t.Fatalf("No results field")
 87	}
 88
 89	_, results, err := authres.Parse(field)
 90	if err != nil {
 91		t.Fatalf("Field parse err: %v", err)
 92	}
 93
 94	for _, res := range results {
 95		dmarcRes, ok := res.(*authres.DMARCResult)
 96		if ok {
 97			return dmarcRes.Value
 98		}
 99	}
100
101	t.Fatalf("No DMARC authres found")
102	return ""
103}
104
105func TestDMARC(t *testing.T) {
106	test := func(zones map[string]mockdns.Zone, hdr string, authres []authres.Result, reject, quarantine bool, dmarcRes authres.ResultValue) {
107		t.Helper()
108
109		tgt := testutils.Target{}
110		p := MsgPipeline{
111			msgpipelineCfg: msgpipelineCfg{
112				globalChecks: []module.Check{
113					&testutils.Check{
114						BodyRes: module.CheckResult{
115							AuthResult: authres,
116						},
117					},
118				},
119				perSource: map[string]sourceBlock{},
120				defaultSource: sourceBlock{
121					perRcpt: map[string]*rcptBlock{},
122					defaultRcpt: &rcptBlock{
123						targets: []module.DeliveryTarget{&tgt},
124					},
125				},
126				doDMARC: true,
127			},
128			Log:      testutils.Logger(t, "pipeline"),
129			Resolver: &mockdns.Resolver{Zones: zones},
130		}
131
132		_, err := doTestDelivery(t, &p, "test@example.org", []string{"test@example.com"}, hdr)
133		if reject {
134			if err == nil {
135				t.Errorf("expected message to be rejected")
136				return
137			}
138			t.Log(err, exterrors.Fields(err))
139			return
140		}
141		if err != nil {
142			t.Errorf("unexpected error: %v %+v", err, exterrors.Fields(err))
143			return
144		}
145
146		if len(tgt.Messages) != 1 {
147			t.Errorf("got %d messages", len(tgt.Messages))
148			return
149		}
150		msg := tgt.Messages[0]
151
152		if msg.MsgMeta.Quarantine != quarantine {
153			t.Errorf("msg.MsgMeta.Quarantine (%v) != quarantine (%v)", msg.MsgMeta.Quarantine, quarantine)
154			return
155		}
156
157		res := dmarcResult(t, msg.Header)
158		if res != dmarcRes {
159			t.Errorf("expected DMARC result to be '%v', got '%v'", dmarcRes, res)
160			return
161		}
162	}
163
164	// No policy => DMARC 'none'
165	test(map[string]mockdns.Zone{}, "From: hello@example.org\r\n\r\n", []authres.Result{
166		&authres.DKIMResult{Value: authres.ResultPass, Domain: "example.org"},
167		&authres.SPFResult{Value: authres.ResultNone, From: "example.org", Helo: "mx.example.org"},
168	}, false, false, authres.ResultNone)
169
170	// Policy present & identifiers align => DMARC 'pass'
171	test(map[string]mockdns.Zone{
172		"_dmarc.example.org.": {
173			TXT: []string{"v=DMARC1; p=none"},
174		},
175	}, "From: hello@example.org\r\n\r\n", []authres.Result{
176		&authres.DKIMResult{Value: authres.ResultPass, Domain: "example.org"},
177		&authres.SPFResult{Value: authres.ResultNone, From: "example.org", Helo: "mx.example.org"},
178	}, false, false, authres.ResultPass)
179
180	// Policy fetch error => DMARC 'permerror' but the message
181	// is accepted.
182	test(map[string]mockdns.Zone{
183		"_dmarc.example.com.": {
184			Err: errors.New("the dns server is going insane"),
185		},
186	}, "From: hello@example.com\r\n\r\n", []authres.Result{
187		&authres.DKIMResult{Value: authres.ResultPass, Domain: "example.org"},
188		&authres.SPFResult{Value: authres.ResultNone, From: "example.org", Helo: "mx.example.org"},
189	}, false, false, authres.ResultPermError)
190
191	// Policy fetch error => DMARC 'temperror' but the message
192	// is rejected ("fail closed")
193	test(map[string]mockdns.Zone{
194		"_dmarc.example.com.": {
195			Err: &net.DNSError{
196				Err:         "the dns server is going insane, temporary",
197				IsTemporary: true,
198			},
199		},
200	}, "From: hello@example.com\r\n\r\n", []authres.Result{
201		&authres.DKIMResult{Value: authres.ResultPass, Domain: "example.org"},
202		&authres.SPFResult{Value: authres.ResultNone, From: "example.org", Helo: "mx.example.org"},
203	}, true, false, authres.ResultTempError)
204
205	// Misaligned From vs DKIM => DMARC 'fail', policy says to reject
206	test(map[string]mockdns.Zone{
207		"_dmarc.example.com.": {
208			TXT: []string{"v=DMARC1; p=reject"},
209		},
210	}, "From: hello@example.com\r\n\r\n", []authres.Result{
211		&authres.DKIMResult{Value: authres.ResultPass, Domain: "example.org"},
212		&authres.SPFResult{Value: authres.ResultNone, From: "example.org", Helo: "mx.example.org"},
213	}, true, false, "")
214
215	// Misaligned From vs DKIM => DMARC 'fail', policy says to quarantine.
216	test(map[string]mockdns.Zone{
217		"_dmarc.example.com.": {
218			TXT: []string{"v=DMARC1; p=quarantine"},
219		},
220	}, "From: hello@example.com\r\n\r\n", []authres.Result{
221		&authres.DKIMResult{Value: authres.ResultPass, Domain: "example.org"},
222		&authres.SPFResult{Value: authres.ResultNone, From: "example.org", Helo: "mx.example.org"},
223	}, false, true, authres.ResultFail)
224}