mirror of
https://github.com/foxcpp/maddy.git
synced 2025-04-05 14:07:38 +03:00
target/remote: Implement STARTTLS Everywhere list support
This commit is contained in:
parent
21b589b5da
commit
c0a73bc3d0
7 changed files with 587 additions and 17 deletions
|
@ -170,6 +170,40 @@ cache can make sense for high-load configurations with good uptime.
|
||||||
|
|
||||||
Filesystem directory to use for policies caching if 'cache' is set to 'fs'.
|
Filesystem directory to use for policies caching if 'cache' is set to 'fs'.
|
||||||
|
|
||||||
|
## Security policies: STARTTLS Everywhere
|
||||||
|
|
||||||
|
Apply rules from the STARTTLS Everywhere list or from any other similarly
|
||||||
|
structured list.
|
||||||
|
|
||||||
|
See https://starttls-everywhere.org for details.
|
||||||
|
|
||||||
|
```
|
||||||
|
sts_preload {
|
||||||
|
source eff
|
||||||
|
early_adopter yes
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
*Syntax*: source eff | file://path | https://path ++
|
||||||
|
*Default*: eff
|
||||||
|
|
||||||
|
Source to download the list from. 'eff' is the alias to use the STARTTLS
|
||||||
|
Everywhere list maintained by EFF. In addition to that, 'eff' alias enables
|
||||||
|
verification of PGP signature using hardcoded PGP key.
|
||||||
|
|
||||||
|
When the argument starts with file://, it should point to the local FS file
|
||||||
|
with the list.
|
||||||
|
When HTTPS URI is used, the list is downloaded from that location.
|
||||||
|
|
||||||
|
In all cases, the list is checked for updates when it is about to expiry.
|
||||||
|
|
||||||
|
*Syntax*: enforce_testing _boolean_ ++
|
||||||
|
*Default*: yes
|
||||||
|
|
||||||
|
Interpret 'testing' records as enforced ones. Using this makes list actually
|
||||||
|
useful for security at the risk of deliverability problems if record becomes
|
||||||
|
out-of-date and host does not publish an MTA-STS policy .
|
||||||
|
|
||||||
## Security policies: DNSSEC
|
## Security policies: DNSSEC
|
||||||
|
|
||||||
Checks whether MX records are signed. Sets MX level to "dnssec" is they are.
|
Checks whether MX records are signed. Sets MX level to "dnssec" is they are.
|
||||||
|
|
|
@ -135,7 +135,7 @@ func (rd *remoteDelivery) attemptMX(ctx context.Context, conn mxConn, record *ne
|
||||||
|
|
||||||
tlsState, _ := conn.Client().TLSConnectionState()
|
tlsState, _ := conn.Client().TLSConnectionState()
|
||||||
for _, p := range rd.policies {
|
for _, p := range rd.policies {
|
||||||
policyLevel, err := p.CheckConn(connCtx, tlsLevel, conn.domain, record.Host, tlsState)
|
policyLevel, err := p.CheckConn(connCtx, mxLevel, tlsLevel, conn.domain, record.Host, tlsState)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
conn.Close()
|
conn.Close()
|
||||||
return exterrors.WithFields(err, map[string]interface{}{"tls_err": tlsErr})
|
return exterrors.WithFields(err, map[string]interface{}{"tls_err": tlsErr})
|
||||||
|
|
|
@ -4,11 +4,14 @@ import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"net"
|
"net"
|
||||||
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/foxcpp/go-mockdns"
|
"github.com/foxcpp/go-mockdns"
|
||||||
"github.com/foxcpp/go-mtasts"
|
"github.com/foxcpp/go-mtasts"
|
||||||
|
"github.com/foxcpp/go-mtasts/preload"
|
||||||
"github.com/foxcpp/maddy/internal/dns"
|
"github.com/foxcpp/maddy/internal/dns"
|
||||||
"github.com/foxcpp/maddy/internal/testutils"
|
"github.com/foxcpp/maddy/internal/testutils"
|
||||||
)
|
)
|
||||||
|
@ -48,6 +51,219 @@ func TestRemoteDelivery_AuthMX_MTASTS(t *testing.T) {
|
||||||
be.CheckMsg(t, 0, "test@example.com", []string{"test@example.invalid"})
|
be.CheckMsg(t, 0, "test@example.com", []string{"test@example.invalid"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRemoteDelivery_AuthMX_STSPreload(t *testing.T) {
|
||||||
|
clientCfg, be, srv := testutils.SMTPServerSTARTTLS(t, "127.0.0.1:"+smtpPort)
|
||||||
|
defer srv.Close()
|
||||||
|
defer testutils.CheckSMTPConnLeak(t, srv)
|
||||||
|
zones := map[string]mockdns.Zone{
|
||||||
|
"example.invalid.": {
|
||||||
|
MX: []net.MX{{Host: "mx.example.invalid.", Pref: 10}},
|
||||||
|
},
|
||||||
|
"mx.example.invalid.": {
|
||||||
|
A: []string{"127.0.0.1"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
download := func(*http.Client, preload.Source) (*preload.List, error) {
|
||||||
|
return &preload.List{
|
||||||
|
Timestamp: preload.ListTime(time.Now()),
|
||||||
|
Expires: preload.ListTime(time.Now().Add(time.Hour)),
|
||||||
|
Version: "0.1",
|
||||||
|
Policies: map[string]preload.Entry{
|
||||||
|
"example.invalid": {
|
||||||
|
Mode: mtasts.ModeEnforce,
|
||||||
|
MXs: []string{"mx.example.invalid"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
tgt := testTarget(t, zones, nil, []Policy{
|
||||||
|
testSTSPreload(t, download),
|
||||||
|
})
|
||||||
|
tgt.tlsConfig = clientCfg
|
||||||
|
defer tgt.Close()
|
||||||
|
|
||||||
|
testutils.DoTestDelivery(t, tgt, "test@example.com", []string{"test@example.invalid"})
|
||||||
|
be.CheckMsg(t, 0, "test@example.com", []string{"test@example.invalid"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRemoteDelivery_AuthMX_STSPreload_Fail(t *testing.T) {
|
||||||
|
clientCfg, be, srv := testutils.SMTPServerSTARTTLS(t, "127.0.0.1:"+smtpPort)
|
||||||
|
defer srv.Close()
|
||||||
|
defer testutils.CheckSMTPConnLeak(t, srv)
|
||||||
|
zones := map[string]mockdns.Zone{
|
||||||
|
"example.invalid.": {
|
||||||
|
MX: []net.MX{{Host: "spoofed.example.invalid.", Pref: 10}},
|
||||||
|
},
|
||||||
|
"mx.example.invalid.": {
|
||||||
|
A: []string{"127.0.0.1"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
download := func(*http.Client, preload.Source) (*preload.List, error) {
|
||||||
|
return &preload.List{
|
||||||
|
Timestamp: preload.ListTime(time.Now()),
|
||||||
|
Expires: preload.ListTime(time.Now().Add(time.Hour)),
|
||||||
|
Version: "0.1",
|
||||||
|
Policies: map[string]preload.Entry{
|
||||||
|
"example.invalid": {
|
||||||
|
Mode: mtasts.ModeEnforce,
|
||||||
|
MXs: []string{"mx.example.invalid"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
tgt := testTarget(t, zones, nil, []Policy{
|
||||||
|
testSTSPreload(t, download),
|
||||||
|
})
|
||||||
|
tgt.tlsConfig = clientCfg
|
||||||
|
tgt.localPolicy.minMXLevel = MX_MTASTS
|
||||||
|
defer tgt.Close()
|
||||||
|
|
||||||
|
_, err := testutils.DoTestDeliveryErr(t, tgt, "test@example.com", []string{"test@example.invalid"})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("Expected an error, got none")
|
||||||
|
}
|
||||||
|
|
||||||
|
if be.MailFromCounter != 0 {
|
||||||
|
t.Fatal("MAIL FROM issued for server failing authentication")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRemoteDelivery_AuthMX_STSPreload_NoTLS(t *testing.T) {
|
||||||
|
be, srv := testutils.SMTPServer(t, "127.0.0.1:"+smtpPort)
|
||||||
|
defer srv.Close()
|
||||||
|
defer testutils.CheckSMTPConnLeak(t, srv)
|
||||||
|
zones := map[string]mockdns.Zone{
|
||||||
|
"example.invalid.": {
|
||||||
|
MX: []net.MX{{Host: "mx.example.invalid.", Pref: 10}},
|
||||||
|
},
|
||||||
|
"mx.example.invalid.": {
|
||||||
|
A: []string{"127.0.0.1"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
download := func(*http.Client, preload.Source) (*preload.List, error) {
|
||||||
|
return &preload.List{
|
||||||
|
Timestamp: preload.ListTime(time.Now()),
|
||||||
|
Expires: preload.ListTime(time.Now().Add(time.Hour)),
|
||||||
|
Version: "0.1",
|
||||||
|
Policies: map[string]preload.Entry{
|
||||||
|
"example.invalid": {
|
||||||
|
Mode: mtasts.ModeEnforce,
|
||||||
|
MXs: []string{"mx.example.invalid"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
tgt := testTarget(t, zones, nil, []Policy{
|
||||||
|
testSTSPreload(t, download),
|
||||||
|
})
|
||||||
|
tgt.localPolicy.minMXLevel = MX_MTASTS
|
||||||
|
defer tgt.Close()
|
||||||
|
|
||||||
|
_, err := testutils.DoTestDeliveryErr(t, tgt, "test@example.com", []string{"test@example.invalid"})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("Expected an error, got none")
|
||||||
|
}
|
||||||
|
|
||||||
|
if be.MailFromCounter != 0 {
|
||||||
|
t.Fatal("MAIL FROM issued for server failing authentication")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRemoteDelivery_AuthMX_STSPreload_RequirePKIX(t *testing.T) {
|
||||||
|
_, be1, srv1 := testutils.SMTPServerSTARTTLS(t, "127.0.0.1:"+smtpPort)
|
||||||
|
defer srv1.Close()
|
||||||
|
defer testutils.CheckSMTPConnLeak(t, srv1)
|
||||||
|
|
||||||
|
zones := map[string]mockdns.Zone{
|
||||||
|
"example.invalid.": {
|
||||||
|
MX: []net.MX{{Host: "mx.example.invalid.", Pref: 10}},
|
||||||
|
},
|
||||||
|
"mx.example.invalid.": {
|
||||||
|
A: []string{"127.0.0.1"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
download := func(*http.Client, preload.Source) (*preload.List, error) {
|
||||||
|
return &preload.List{
|
||||||
|
Timestamp: preload.ListTime(time.Now()),
|
||||||
|
Expires: preload.ListTime(time.Now().Add(time.Hour)),
|
||||||
|
Version: "0.1",
|
||||||
|
Policies: map[string]preload.Entry{
|
||||||
|
"example.invalid": {
|
||||||
|
Mode: mtasts.ModeEnforce,
|
||||||
|
MXs: []string{"mx.example.invalid"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
tgt := testTarget(t, zones, nil, []Policy{
|
||||||
|
testSTSPreload(t, download),
|
||||||
|
})
|
||||||
|
tgt.localPolicy.minMXLevel = MX_MTASTS
|
||||||
|
defer tgt.Close()
|
||||||
|
|
||||||
|
_, err := testutils.DoTestDeliveryErr(t, tgt, "test@example.com", []string{"test@example.invalid"})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("Expected an error, got none")
|
||||||
|
}
|
||||||
|
|
||||||
|
if be1.MailFromCounter != 0 {
|
||||||
|
t.Fatal("MAIL FROM issued for server failing authentication")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRemoteDelivery_AuthMX_STSPreload_MTASTS(t *testing.T) {
|
||||||
|
clientCfg, be, srv1 := testutils.SMTPServerSTARTTLS(t, "127.0.0.1:"+smtpPort)
|
||||||
|
defer srv1.Close()
|
||||||
|
defer testutils.CheckSMTPConnLeak(t, srv1)
|
||||||
|
|
||||||
|
zones := map[string]mockdns.Zone{
|
||||||
|
"example.invalid.": {
|
||||||
|
MX: []net.MX{{Host: "mx.example.invalid.", Pref: 10}},
|
||||||
|
},
|
||||||
|
"mx.example.invalid.": {
|
||||||
|
A: []string{"127.0.0.1"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
mtastsGet := func(ctx context.Context, domain string) (*mtasts.Policy, error) {
|
||||||
|
if domain != "example.invalid" {
|
||||||
|
return nil, errors.New("Wrong domain in lookup")
|
||||||
|
}
|
||||||
|
|
||||||
|
return &mtasts.Policy{
|
||||||
|
Mode: mtasts.ModeEnforce,
|
||||||
|
MX: []string{"mx.example.invalid"},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
download := func(*http.Client, preload.Source) (*preload.List, error) {
|
||||||
|
return &preload.List{
|
||||||
|
Timestamp: preload.ListTime(time.Now()),
|
||||||
|
Expires: preload.ListTime(time.Now().Add(time.Hour)),
|
||||||
|
Version: "0.1",
|
||||||
|
Policies: map[string]preload.Entry{
|
||||||
|
"example.invalid": {
|
||||||
|
Mode: mtasts.ModeEnforce,
|
||||||
|
MXs: []string{"outdated.example.invalid"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
tgt := testTarget(t, zones, nil, []Policy{
|
||||||
|
testSTSPolicy(t, zones, mtastsGet),
|
||||||
|
testSTSPreload(t, download),
|
||||||
|
})
|
||||||
|
tgt.tlsConfig = clientCfg
|
||||||
|
tgt.localPolicy.minMXLevel = MX_MTASTS
|
||||||
|
defer tgt.Close()
|
||||||
|
|
||||||
|
testutils.DoTestDelivery(t, tgt, "test@example.com", []string{"test@example.invalid"})
|
||||||
|
be.CheckMsg(t, 0, "test@example.com", []string{"test@example.invalid"})
|
||||||
|
}
|
||||||
|
|
||||||
func TestRemoteDelivery_MTASTS_SkipNonMatching(t *testing.T) {
|
func TestRemoteDelivery_MTASTS_SkipNonMatching(t *testing.T) {
|
||||||
_, be1, srv1 := testutils.SMTPServerSTARTTLS(t, "127.0.0.1:"+smtpPort)
|
_, be1, srv1 := testutils.SMTPServerSTARTTLS(t, "127.0.0.1:"+smtpPort)
|
||||||
defer srv1.Close()
|
defer srv1.Close()
|
||||||
|
|
|
@ -123,19 +123,24 @@ func (rt *Target) Init(cfg *config.Map) error {
|
||||||
return &tls.Config{}, nil
|
return &tls.Config{}, nil
|
||||||
}, config.TLSClientBlock, &rt.tlsConfig)
|
}, config.TLSClientBlock, &rt.tlsConfig)
|
||||||
|
|
||||||
policies := make([]Policy, 3)
|
policies := make([]Policy, 4)
|
||||||
cfg.Custom("mtasts", false, false,
|
cfg.Custom("mtasts", false, false,
|
||||||
rt.defaultPolicy(cfg.Globals, "mtasts"), rt.policyMatcher("mtasts"), &policies[0])
|
rt.defaultPolicy(cfg.Globals, "mtasts"), rt.policyMatcher("mtasts"), &policies[0])
|
||||||
|
// sts_preload should go after mtasts so it will take not effect if MXLevel is already MX_MTASTS.
|
||||||
|
cfg.Custom("sts_preload", false, false,
|
||||||
|
rt.defaultPolicy(cfg.Globals, "sts_preload"), rt.policyMatcher("sts_preload"), &policies[1])
|
||||||
cfg.Custom("dane", false, false,
|
cfg.Custom("dane", false, false,
|
||||||
rt.defaultPolicy(cfg.Globals, "dane"), rt.policyMatcher("dane"), &policies[1])
|
rt.defaultPolicy(cfg.Globals, "dane"), rt.policyMatcher("dane"), &policies[2])
|
||||||
cfg.Custom("dnssec", false, false,
|
cfg.Custom("dnssec", false, false,
|
||||||
rt.defaultPolicy(cfg.Globals, "dnssec"), rt.policyMatcher("dnssec"), &policies[2])
|
rt.defaultPolicy(cfg.Globals, "dnssec"), rt.policyMatcher("dnssec"), &policies[3])
|
||||||
|
|
||||||
|
var localPolicy localPolicy
|
||||||
|
|
||||||
// localPolicy should be the last one, since it considers levels defined by
|
// localPolicy should be the last one, since it considers levels defined by
|
||||||
// other policies.
|
// other policies.
|
||||||
// Also, it should be directly accessible from tests to adjust required levels.
|
// Also, it should be directly accessible from tests to adjust required levels.
|
||||||
cfg.Custom("local_policy", false, false,
|
cfg.Custom("local_policy", false, false,
|
||||||
rt.defaultPolicy(cfg.Globals, "local"), rt.policyMatcher("local"), &rt.localPolicy)
|
rt.defaultPolicy(cfg.Globals, "local"), rt.policyMatcher("local"), &localPolicy)
|
||||||
|
|
||||||
if _, err := cfg.Process(); err != nil {
|
if _, err := cfg.Process(); err != nil {
|
||||||
return err
|
return err
|
||||||
|
@ -148,7 +153,8 @@ func (rt *Target) Init(cfg *config.Map) error {
|
||||||
}
|
}
|
||||||
rt.policies = append(rt.policies, p)
|
rt.policies = append(rt.policies, p)
|
||||||
}
|
}
|
||||||
rt.policies = append(rt.policies, rt.localPolicy)
|
rt.localPolicy = &localPolicy
|
||||||
|
rt.policies = append(rt.policies, &localPolicy)
|
||||||
|
|
||||||
// INTERNATIONALIZATION: See RFC 6531 Section 3.7.1.
|
// INTERNATIONALIZATION: See RFC 6531 Section 3.7.1.
|
||||||
rt.hostname, err = idna.ToASCII(rt.hostname)
|
rt.hostname, err = idna.ToASCII(rt.hostname)
|
||||||
|
|
|
@ -6,6 +6,7 @@ import (
|
||||||
"flag"
|
"flag"
|
||||||
"math/rand"
|
"math/rand"
|
||||||
"net"
|
"net"
|
||||||
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
"testing"
|
"testing"
|
||||||
|
@ -65,6 +66,23 @@ func testSTSPolicy(t *testing.T, zones map[string]mockdns.Zone, mtastsGet func(c
|
||||||
return p
|
return p
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testSTSPreload(t *testing.T, download FuncPreloadList) *stsPreloadPolicy {
|
||||||
|
p, err := NewSTSPreloadPolicy(false, http.DefaultClient, download, config.NewMap(nil, &config.Node{
|
||||||
|
Children: []config.Node{
|
||||||
|
{
|
||||||
|
Name: "source",
|
||||||
|
Args: []string{"https://127.0.0.1:1111"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
p.log = testutils.Logger(t, "remote/preload")
|
||||||
|
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
func testDANEPolicy(t *testing.T, extR *dns.ExtResolver) *danePolicy {
|
func testDANEPolicy(t *testing.T, extR *dns.ExtResolver) *danePolicy {
|
||||||
p := NewDANEPolicy(extR, false)
|
p := NewDANEPolicy(extR, false)
|
||||||
p.log = testutils.Logger(t, "remote/dane")
|
p.log = testutils.Logger(t, "remote/dane")
|
||||||
|
|
|
@ -3,10 +3,16 @@ package remote
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/foxcpp/go-mtasts"
|
"github.com/foxcpp/go-mtasts"
|
||||||
|
"github.com/foxcpp/go-mtasts/preload"
|
||||||
"github.com/foxcpp/maddy/internal/config"
|
"github.com/foxcpp/maddy/internal/config"
|
||||||
"github.com/foxcpp/maddy/internal/dns"
|
"github.com/foxcpp/maddy/internal/dns"
|
||||||
"github.com/foxcpp/maddy/internal/exterrors"
|
"github.com/foxcpp/maddy/internal/exterrors"
|
||||||
|
@ -77,8 +83,8 @@ type (
|
||||||
// CheckConn is called to check whether the policy permits to use this
|
// CheckConn is called to check whether the policy permits to use this
|
||||||
// connection.
|
// connection.
|
||||||
//
|
//
|
||||||
// tlsLevel contains the TLS security level estabilished by checks
|
// tlsLevel and mxLevel contain the TLS security level estabilished by
|
||||||
// executed before.
|
// checks executed before.
|
||||||
//
|
//
|
||||||
// domain is passed to the CheckConn to allow simpler implementation
|
// domain is passed to the CheckConn to allow simpler implementation
|
||||||
// of stateless policy objects.
|
// of stateless policy objects.
|
||||||
|
@ -86,7 +92,7 @@ type (
|
||||||
// If tlsState.HandshakeCompleted is false, TLS is not used. If
|
// If tlsState.HandshakeCompleted is false, TLS is not used. If
|
||||||
// tlsState.VerifiedChains is nil, InsecureSkipVerify was used (no
|
// tlsState.VerifiedChains is nil, InsecureSkipVerify was used (no
|
||||||
// ServerName or PKI check was done).
|
// ServerName or PKI check was done).
|
||||||
CheckConn(ctx context.Context, tlsLevel TLSLevel, domain, mx string, tlsState tls.ConnectionState) (TLSLevel, error)
|
CheckConn(ctx context.Context, mxLevel MXLevel, tlsLevel TLSLevel, domain, mx string, tlsState tls.ConnectionState) (TLSLevel, error)
|
||||||
|
|
||||||
// Reset cleans the internal object state for use with another message.
|
// Reset cleans the internal object state for use with another message.
|
||||||
// newMsg may be nil if object is not needed anymore.
|
// newMsg may be nil if object is not needed anymore.
|
||||||
|
@ -213,7 +219,7 @@ func (c *mtastsDelivery) CheckMX(ctx context.Context, mxLevel MXLevel, domain, m
|
||||||
return MX_MTASTS, nil
|
return MX_MTASTS, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *mtastsDelivery) CheckConn(ctx context.Context, tlsLevel TLSLevel, domain, mx string, tlsState tls.ConnectionState) (TLSLevel, error) {
|
func (c *mtastsDelivery) CheckConn(ctx context.Context, mxLevel MXLevel, tlsLevel TLSLevel, domain, mx string, tlsState tls.ConnectionState) (TLSLevel, error) {
|
||||||
policyI, err := c.policyFut.GetContext(ctx)
|
policyI, err := c.policyFut.GetContext(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.c.log.DebugMsg("MTA-STS error", "err", err)
|
c.c.log.DebugMsg("MTA-STS error", "err", err)
|
||||||
|
@ -255,6 +261,277 @@ func (c *mtastsDelivery) Reset(msgMeta *module.MsgMetadata) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
// Delay between list expiry and first update attempt.
|
||||||
|
preloadUpdateGrace = 5 * time.Minute
|
||||||
|
|
||||||
|
// Minimal time between preload list update attempts.
|
||||||
|
//
|
||||||
|
// This is adjusted for preloadUpdateGrace so we will the chance to make 10
|
||||||
|
// attempts to update list before it expires.
|
||||||
|
preloadUpdateCooldown = 30 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
type (
|
||||||
|
FuncPreloadList = func(*http.Client, preload.Source) (*preload.List, error)
|
||||||
|
stsPreloadPolicy struct {
|
||||||
|
l *preload.List
|
||||||
|
lLock sync.RWMutex
|
||||||
|
log log.Logger
|
||||||
|
|
||||||
|
updaterStop chan struct{}
|
||||||
|
|
||||||
|
sourcePath string
|
||||||
|
client *http.Client
|
||||||
|
listDownload FuncPreloadList
|
||||||
|
enforceTesting bool
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
func NewSTSPreloadPolicy(debug bool, client *http.Client, listDownload FuncPreloadList, cfg *config.Map) (*stsPreloadPolicy, error) {
|
||||||
|
p := &stsPreloadPolicy{
|
||||||
|
log: log.Logger{Name: "remote/preload", Debug: debug},
|
||||||
|
updaterStop: make(chan struct{}),
|
||||||
|
client: client,
|
||||||
|
listDownload: preload.Download,
|
||||||
|
}
|
||||||
|
|
||||||
|
var sourcePath string
|
||||||
|
cfg.String("source", false, false, "eff", &sourcePath)
|
||||||
|
cfg.Bool("enforceTesting", false, true, &p.enforceTesting)
|
||||||
|
if _, err := cfg.Process(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var err error
|
||||||
|
p.l, err = p.load(client, listDownload, sourcePath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
p.sourcePath = sourcePath
|
||||||
|
|
||||||
|
go p.updater()
|
||||||
|
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *stsPreloadPolicy) load(client *http.Client, listDownload FuncPreloadList, sourcePath string) (*preload.List, error) {
|
||||||
|
var (
|
||||||
|
l *preload.List
|
||||||
|
err error
|
||||||
|
)
|
||||||
|
src := preload.Source{
|
||||||
|
ListURI: sourcePath,
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(sourcePath, "file://"):
|
||||||
|
// Load list from FS.
|
||||||
|
path := strings.TrimPrefix(sourcePath, "file://")
|
||||||
|
f, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the list is provided by an external FS source, it is its
|
||||||
|
// responsibility to make sure it is valid. Hard-fail if it is not.
|
||||||
|
l, err = preload.Read(f)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("remote/preload: %w", err)
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
p.log.DebugMsg("loaded list from FS", "entries", len(l.Policies), "path", path)
|
||||||
|
case sourcePath == "eff":
|
||||||
|
src = preload.STARTTLSEverywhere
|
||||||
|
fallthrough
|
||||||
|
case strings.HasPrefix(sourcePath, "http://"):
|
||||||
|
// XXX: Only for testing, remove later.
|
||||||
|
fallthrough
|
||||||
|
case strings.HasPrefix(sourcePath, "https://"):
|
||||||
|
// Download list using HTTPS.
|
||||||
|
// TODO: Cache on disk and update it asynchronously to reduce start-up
|
||||||
|
// time. This will also reduce persistent attacker ability to prevent
|
||||||
|
// list (re-)discovery.
|
||||||
|
p.log.DebugMsg("downloading list", "uri", sourcePath)
|
||||||
|
|
||||||
|
l, err = listDownload(client, src)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("remote/preload: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
p.log.DebugMsg("downloaded list", "entries", len(l.Policies), "uri", sourcePath)
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("remote/preload: unknown list source or unsupported schema: %v", sourcePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
return l, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *stsPreloadPolicy) updater() {
|
||||||
|
for {
|
||||||
|
updateDelay := time.Until(time.Time(p.l.Expires)) - preloadUpdateGrace
|
||||||
|
if updateDelay <= 0 {
|
||||||
|
updateDelay = preloadUpdateCooldown
|
||||||
|
}
|
||||||
|
// TODO: Increase update delay for multiple failures.
|
||||||
|
|
||||||
|
t := time.NewTimer(updateDelay)
|
||||||
|
|
||||||
|
p.log.DebugMsg("sleeping to update", "delay", updateDelay)
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-t.C:
|
||||||
|
// Attempt to update the list.
|
||||||
|
newList, err := p.load(p.client, p.listDownload, p.sourcePath)
|
||||||
|
if err != nil {
|
||||||
|
p.log.Error("failed to update list", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := p.update(newList); err != nil {
|
||||||
|
p.log.Error("failed to update list", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
p.log.DebugMsg("updated list", "entries", len(newList.Policies))
|
||||||
|
case <-p.updaterStop:
|
||||||
|
t.Stop()
|
||||||
|
p.updaterStop <- struct{}{}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *stsPreloadPolicy) update(newList *preload.List) error {
|
||||||
|
if newList.Expired() {
|
||||||
|
return exterrors.WithFields(errors.New("the new STARTLS Everywhere list is expired"),
|
||||||
|
map[string]interface{}{
|
||||||
|
"timestamp": newList.Timestamp,
|
||||||
|
"expires": newList.Expires,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
p.lLock.Lock()
|
||||||
|
defer p.lLock.Unlock()
|
||||||
|
|
||||||
|
if time.Time(newList.Timestamp).Before(time.Time(p.l.Timestamp)) {
|
||||||
|
return exterrors.WithFields(errors.New("the new list is older than the currently used one"),
|
||||||
|
map[string]interface{}{
|
||||||
|
"old_timestamp": p.l.Timestamp,
|
||||||
|
"new_timestamp": newList.Timestamp,
|
||||||
|
"expires": newList.Expires,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
p.l = newList
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type preloadDelivery struct {
|
||||||
|
*stsPreloadPolicy
|
||||||
|
mtastsPresent bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *stsPreloadPolicy) Start(*module.MsgMetadata) DeliveryPolicy {
|
||||||
|
return &preloadDelivery{stsPreloadPolicy: p}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *preloadDelivery) Reset(*module.MsgMetadata) {}
|
||||||
|
func (p *preloadDelivery) PrepareDomain(ctx context.Context, domain string) {}
|
||||||
|
func (p *preloadDelivery) PrepareConn(ctx context.Context, mx string) {}
|
||||||
|
func (p *preloadDelivery) CheckMX(ctx context.Context, mxLevel MXLevel, domain, mx string, dnssec bool) (MXLevel, error) {
|
||||||
|
// MTA-STS policy was discovered and took effect already. Do not use
|
||||||
|
// preload list.
|
||||||
|
if mxLevel == MX_MTASTS {
|
||||||
|
p.mtastsPresent = true
|
||||||
|
return MXNone, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
p.lLock.RLock()
|
||||||
|
defer p.lLock.RUnlock()
|
||||||
|
|
||||||
|
if p.l.Expired() {
|
||||||
|
p.log.Msg("STARTTLS Everywhere list is expired, ignoring")
|
||||||
|
return MXNone, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
ent, ok := p.l.Lookup(domain)
|
||||||
|
if !ok {
|
||||||
|
p.log.DebugMsg("no entry", "domain", domain)
|
||||||
|
return MXNone, nil
|
||||||
|
}
|
||||||
|
sts := ent.STS(p.l)
|
||||||
|
if !sts.Match(mx) {
|
||||||
|
if sts.Mode == mtasts.ModeEnforce || p.enforceTesting {
|
||||||
|
return MXNone, &exterrors.SMTPError{
|
||||||
|
Code: 550,
|
||||||
|
EnhancedCode: exterrors.EnhancedCode{5, 7, 0},
|
||||||
|
Message: "Failed to estabilish the MX record authenticity (STARTTLS Everywhere)",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p.log.Msg("MX does not match published non-enforced STARTLS Everywhere entry", "mx", mx, "domain", domain)
|
||||||
|
return MXNone, nil
|
||||||
|
}
|
||||||
|
p.log.DebugMsg("MX OK", "domain", domain)
|
||||||
|
return MX_MTASTS, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *preloadDelivery) CheckConn(ctx context.Context, mxLevel MXLevel, tlsLevel TLSLevel, domain, mx string, tlsState tls.ConnectionState) (TLSLevel, error) {
|
||||||
|
// MTA-STS policy was discovered and took effect already. Do not use
|
||||||
|
// preload list. We cannot check level for MX_MTASTS because we can set
|
||||||
|
// it too in CheckMX.
|
||||||
|
if p.mtastsPresent {
|
||||||
|
return TLSNone, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
p.lLock.RLock()
|
||||||
|
defer p.lLock.RUnlock()
|
||||||
|
|
||||||
|
if p.l.Expired() {
|
||||||
|
p.log.Msg("STARTTLS Everywhere list is expired, ignoring")
|
||||||
|
return TLSNone, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
ent, ok := p.l.Lookup(domain)
|
||||||
|
if !ok {
|
||||||
|
p.log.DebugMsg("no entry", "domain", domain)
|
||||||
|
return TLSNone, nil
|
||||||
|
}
|
||||||
|
if ent.Mode != mtasts.ModeEnforce && !p.enforceTesting {
|
||||||
|
return TLSNone, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if !tlsState.HandshakeComplete {
|
||||||
|
return TLSNone, &exterrors.SMTPError{
|
||||||
|
Code: 451,
|
||||||
|
EnhancedCode: exterrors.EnhancedCode{4, 7, 1},
|
||||||
|
Message: "TLS is required but unavailable or failed (STARTTLS Everywhere)",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if tlsState.VerifiedChains == nil {
|
||||||
|
return TLSNone, &exterrors.SMTPError{
|
||||||
|
Code: 451,
|
||||||
|
EnhancedCode: exterrors.EnhancedCode{4, 7, 1},
|
||||||
|
Message: "Recipient server TLS certificate is not trusted but " +
|
||||||
|
"authentication is required by STARTTLS Everywhere list",
|
||||||
|
Misc: map[string]interface{}{
|
||||||
|
"tls_level": tlsLevel,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
p.log.DebugMsg("TLS OK", "domain", domain)
|
||||||
|
|
||||||
|
return TLSNone, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *stsPreloadPolicy) Close() error {
|
||||||
|
p.updaterStop <- struct{}{}
|
||||||
|
<-p.updaterStop
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
type dnssecPolicy struct{}
|
type dnssecPolicy struct{}
|
||||||
|
|
||||||
func (dnssecPolicy) Start(*module.MsgMetadata) DeliveryPolicy {
|
func (dnssecPolicy) Start(*module.MsgMetadata) DeliveryPolicy {
|
||||||
|
@ -276,7 +553,7 @@ func (dnssecPolicy) CheckMX(ctx context.Context, mxLevel MXLevel, domain, mx str
|
||||||
return MXNone, nil
|
return MXNone, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (dnssecPolicy) CheckConn(ctx context.Context, tlsLevel TLSLevel, domain, mx string, tlsState tls.ConnectionState) (TLSLevel, error) {
|
func (dnssecPolicy) CheckConn(ctx context.Context, mxLevel MXLevel, tlsLevel TLSLevel, domain, mx string, tlsState tls.ConnectionState) (TLSLevel, error) {
|
||||||
return TLSNone, nil
|
return TLSNone, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -341,7 +618,7 @@ func (c *daneDelivery) CheckMX(ctx context.Context, mxLevel MXLevel, domain, mx
|
||||||
return MXNone, nil
|
return MXNone, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *daneDelivery) CheckConn(ctx context.Context, tlsLevel TLSLevel, domain, mx string, tlsState tls.ConnectionState) (TLSLevel, error) {
|
func (c *daneDelivery) CheckConn(ctx context.Context, mxLevel MXLevel, tlsLevel TLSLevel, domain, mx string, tlsState tls.ConnectionState) (TLSLevel, error) {
|
||||||
// No DNSSEC support.
|
// No DNSSEC support.
|
||||||
if c.c.extResolver == nil {
|
if c.c.extResolver == nil {
|
||||||
return TLSNone, nil
|
return TLSNone, nil
|
||||||
|
@ -449,7 +726,7 @@ func (l localPolicy) CheckMX(ctx context.Context, mxLevel MXLevel, domain, mx st
|
||||||
return MXNone, nil
|
return MXNone, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (l localPolicy) CheckConn(ctx context.Context, tlsLevel TLSLevel, domain, mx string, tlsState tls.ConnectionState) (TLSLevel, error) {
|
func (l localPolicy) CheckConn(ctx context.Context, mxLevel MXLevel, tlsLevel TLSLevel, domain, mx string, tlsState tls.ConnectionState) (TLSLevel, error) {
|
||||||
if tlsLevel < l.minTLSLevel {
|
if tlsLevel < l.minTLSLevel {
|
||||||
return TLSNone, &exterrors.SMTPError{
|
return TLSNone, &exterrors.SMTPError{
|
||||||
Code: 451,
|
Code: 451,
|
||||||
|
@ -465,6 +742,8 @@ func (l localPolicy) CheckConn(ctx context.Context, tlsLevel TLSLevel, domain, m
|
||||||
|
|
||||||
func (rt *Target) policyMatcher(name string) func(*config.Map, *config.Node) (interface{}, error) {
|
func (rt *Target) policyMatcher(name string) func(*config.Map, *config.Node) (interface{}, error) {
|
||||||
return func(m *config.Map, node *config.Node) (interface{}, error) {
|
return func(m *config.Map, node *config.Node) (interface{}, error) {
|
||||||
|
// TODO: Fix rt.Log.Debug propagation. This function is called in
|
||||||
|
// arbitrary order before or after the 'debug' directive is handled.
|
||||||
switch len(node.Args) {
|
switch len(node.Args) {
|
||||||
case 0:
|
case 0:
|
||||||
case 1:
|
case 1:
|
||||||
|
@ -482,17 +761,20 @@ func (rt *Target) policyMatcher(name string) func(*config.Map, *config.Node) (in
|
||||||
)
|
)
|
||||||
switch name {
|
switch name {
|
||||||
case "mtasts":
|
case "mtasts":
|
||||||
policy, err = NewMTASTSPolicy(rt.resolver, rt.Log.Debug, config.NewMap(m.Globals, node))
|
policy, err = NewMTASTSPolicy(rt.resolver, log.DefaultLogger.Debug, config.NewMap(m.Globals, node))
|
||||||
case "dane":
|
case "dane":
|
||||||
if node.Children != nil {
|
if node.Children != nil {
|
||||||
return nil, m.MatchErr("policy offers no additional configuration")
|
return nil, m.MatchErr("policy offers no additional configuration")
|
||||||
}
|
}
|
||||||
policy = NewDANEPolicy(rt.extResolver, rt.Log.Debug)
|
policy = NewDANEPolicy(rt.extResolver, log.DefaultLogger.Debug)
|
||||||
case "dnssec":
|
case "dnssec":
|
||||||
if node.Children != nil {
|
if node.Children != nil {
|
||||||
return nil, m.MatchErr("policy offers no additional configuration")
|
return nil, m.MatchErr("policy offers no additional configuration")
|
||||||
}
|
}
|
||||||
policy = &dnssecPolicy{}
|
policy = &dnssecPolicy{}
|
||||||
|
case "sts_preload":
|
||||||
|
policy, err = NewSTSPreloadPolicy(log.DefaultLogger.Debug, http.DefaultClient, preload.Download,
|
||||||
|
config.NewMap(m.Globals, node))
|
||||||
case "local":
|
case "local":
|
||||||
policy, err = NewLocalPolicy(config.NewMap(m.Globals, node))
|
policy, err = NewLocalPolicy(config.NewMap(m.Globals, node))
|
||||||
default:
|
default:
|
||||||
|
@ -508,17 +790,22 @@ func (rt *Target) policyMatcher(name string) func(*config.Map, *config.Node) (in
|
||||||
|
|
||||||
func (rt *Target) defaultPolicy(globals map[string]interface{}, name string) func() (interface{}, error) {
|
func (rt *Target) defaultPolicy(globals map[string]interface{}, name string) func() (interface{}, error) {
|
||||||
return func() (interface{}, error) {
|
return func() (interface{}, error) {
|
||||||
|
// TODO: Fix rt.Log.Debug propagation. This function is called in
|
||||||
|
// arbitrary order before or after the 'debug' directive is handled.
|
||||||
var (
|
var (
|
||||||
policy Policy
|
policy Policy
|
||||||
err error
|
err error
|
||||||
)
|
)
|
||||||
switch name {
|
switch name {
|
||||||
case "mtasts":
|
case "mtasts":
|
||||||
policy, err = NewMTASTSPolicy(rt.resolver, rt.Log.Debug, config.NewMap(globals, &config.Node{}))
|
policy, err = NewMTASTSPolicy(rt.resolver, log.DefaultLogger.Debug, config.NewMap(globals, &config.Node{}))
|
||||||
case "dane":
|
case "dane":
|
||||||
policy = NewDANEPolicy(rt.extResolver, rt.Log.Debug)
|
policy = NewDANEPolicy(rt.extResolver, log.DefaultLogger.Debug)
|
||||||
case "dnssec":
|
case "dnssec":
|
||||||
policy = &dnssecPolicy{}
|
policy = &dnssecPolicy{}
|
||||||
|
case "sts_preload":
|
||||||
|
policy, err = NewSTSPreloadPolicy(log.DefaultLogger.Debug, http.DefaultClient, preload.Download,
|
||||||
|
config.NewMap(globals, &config.Node{}))
|
||||||
case "local":
|
case "local":
|
||||||
policy, err = NewLocalPolicy(config.NewMap(globals, &config.Node{}))
|
policy, err = NewLocalPolicy(config.NewMap(globals, &config.Node{}))
|
||||||
default:
|
default:
|
||||||
|
|
|
@ -139,6 +139,15 @@ queue remote_queue {
|
||||||
fs_dir mtasts_cache/
|
fs_dir mtasts_cache/
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Use STARTTLS Everywhere list to preload MTA-STS cache.
|
||||||
|
# See https://startls-everywhere.org for details.
|
||||||
|
sts_preload {
|
||||||
|
source eff
|
||||||
|
|
||||||
|
# Apply testing-only entries as if they were enforced.
|
||||||
|
enforce_testing yes
|
||||||
|
}
|
||||||
|
|
||||||
local_policy {
|
local_policy {
|
||||||
# Require at least unauthenticated TLS to protect against passive
|
# Require at least unauthenticated TLS to protect against passive
|
||||||
# attacks.
|
# attacks.
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue