mirror of
https://github.com/mjl-/mox.git
synced 2025-07-12 17:44:35 +03:00
mox!
This commit is contained in:
6
tlsrpt/doc.go
Normal file
6
tlsrpt/doc.go
Normal file
@ -0,0 +1,6 @@
|
||||
// Package tlsrpt implements SMTP TLS Reporting, RFC 8460.
|
||||
//
|
||||
// TLSRPT allows a domain to publish a policy requesting feedback of TLS
|
||||
// connectivity to its SMTP servers. Reports can be sent to an address defined
|
||||
// in the TLSRPT DNS record. These reports can be parsed by tlsrpt.
|
||||
package tlsrpt
|
91
tlsrpt/lookup.go
Normal file
91
tlsrpt/lookup.go
Normal file
@ -0,0 +1,91 @@
|
||||
package tlsrpt
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||
|
||||
"github.com/mjl-/mox/dns"
|
||||
"github.com/mjl-/mox/mlog"
|
||||
)
|
||||
|
||||
var xlog = mlog.New("tlsrpt")
|
||||
|
||||
var (
|
||||
metricLookup = promauto.NewHistogramVec(
|
||||
prometheus.HistogramOpts{
|
||||
Name: "mox_tlsrpt_lookup_duration_seconds",
|
||||
Help: "TLSRPT lookups with result.",
|
||||
Buckets: []float64{0.001, 0.005, 0.01, 0.05, 0.100, 0.5, 1, 5, 10, 20, 30},
|
||||
},
|
||||
[]string{"result"},
|
||||
)
|
||||
)
|
||||
|
||||
var (
|
||||
ErrNoRecord = errors.New("tlsrpt: no tlsrpt dns txt record")
|
||||
ErrMultipleRecords = errors.New("tlsrpt: multiple tlsrpt records") // Must be treated as if domain does not implement TLSRPT.
|
||||
ErrDNS = errors.New("tlsrpt: temporary error")
|
||||
ErrRecordSyntax = errors.New("tlsrpt: record syntax error")
|
||||
)
|
||||
|
||||
// Lookup looks up a TLSRPT DNS TXT record for domain at "_smtp._tls.<domain>" and
|
||||
// parses it.
|
||||
func Lookup(ctx context.Context, resolver dns.Resolver, domain dns.Domain) (rrecord *Record, rtxt string, rerr error) {
|
||||
log := xlog.WithContext(ctx)
|
||||
start := time.Now()
|
||||
defer func() {
|
||||
result := "ok"
|
||||
if rerr != nil {
|
||||
if errors.Is(rerr, ErrNoRecord) {
|
||||
result = "notfound"
|
||||
} else if errors.Is(rerr, ErrMultipleRecords) {
|
||||
result = "multiple"
|
||||
} else if errors.Is(rerr, ErrDNS) {
|
||||
result = "temperror"
|
||||
} else if errors.Is(rerr, ErrRecordSyntax) {
|
||||
result = "malformed"
|
||||
} else {
|
||||
result = "error"
|
||||
}
|
||||
}
|
||||
metricLookup.WithLabelValues(result).Observe(float64(time.Since(start)) / float64(time.Second))
|
||||
log.Debugx("tlsrpt lookup result", rerr, mlog.Field("domain", domain), mlog.Field("record", rrecord), mlog.Field("duration", time.Since(start)))
|
||||
}()
|
||||
|
||||
name := "_smtp._tls." + domain.ASCII + "."
|
||||
txts, err := dns.WithPackage(resolver, "tlsrpt").LookupTXT(ctx, name)
|
||||
if dns.IsNotFound(err) {
|
||||
return nil, "", ErrNoRecord
|
||||
} else if err != nil {
|
||||
return nil, "", fmt.Errorf("%w: %s", ErrDNS, err)
|
||||
}
|
||||
|
||||
var text string
|
||||
var record *Record
|
||||
for _, txt := range txts {
|
||||
r, istlsrpt, err := ParseRecord(txt)
|
||||
if !istlsrpt {
|
||||
// This is a loose but probably reasonable interpretation of ../rfc/8460:375 which
|
||||
// wants us to discard otherwise valid records that start with e.g. "v=TLSRPTv1 ;"
|
||||
// (note the space before the ";") when multiple TXT records were returned.
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("parsing record: %w", err)
|
||||
}
|
||||
if record != nil {
|
||||
return nil, "", ErrMultipleRecords
|
||||
}
|
||||
record = r
|
||||
text = txt
|
||||
}
|
||||
if record == nil {
|
||||
return nil, "", ErrNoRecord
|
||||
}
|
||||
return record, text, nil
|
||||
}
|
46
tlsrpt/lookup_test.go
Normal file
46
tlsrpt/lookup_test.go
Normal file
@ -0,0 +1,46 @@
|
||||
package tlsrpt
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/mjl-/mox/dns"
|
||||
)
|
||||
|
||||
func TestLookup(t *testing.T) {
|
||||
resolver := dns.MockResolver{
|
||||
TXT: map[string][]string{
|
||||
"_smtp._tls.basic.example.": {"v=TLSRPTv1; rua=mailto:tlsrpt@basic.example"},
|
||||
"_smtp._tls.one.example.": {"v=TLSRPTv1; rua=mailto:tlsrpt@basic.example", "other"},
|
||||
"_smtp._tls.multiple.example.": {"v=TLSRPTv1; rua=mailto:tlsrpt@basic.example", "v=TLSRPTv1; rua=mailto:tlsrpt@basic.example"},
|
||||
"_smtp._tls.malformed.example.": {"v=TLSRPTv1; bad"},
|
||||
"_smtp._tls.other.example.": {"other"},
|
||||
},
|
||||
Fail: map[dns.Mockreq]struct{}{
|
||||
{Type: "txt", Name: "_smtp._tls.temperror.example."}: {},
|
||||
},
|
||||
}
|
||||
|
||||
test := func(domain string, expRecord *Record, expErr error) {
|
||||
t.Helper()
|
||||
|
||||
d := dns.Domain{ASCII: domain}
|
||||
record, _, err := Lookup(context.Background(), resolver, d)
|
||||
if (err == nil) != (expErr == nil) || err != nil && !errors.Is(err, expErr) {
|
||||
t.Fatalf("lookup, got err %#v, expected %#v", err, expErr)
|
||||
}
|
||||
if err == nil && !reflect.DeepEqual(record, expRecord) {
|
||||
t.Fatalf("lookup, got %#v, expected %#v", record, expRecord)
|
||||
}
|
||||
}
|
||||
|
||||
test("basic.example", &Record{Version: "TLSRPTv1", RUAs: [][]string{{"mailto:tlsrpt@basic.example"}}}, nil)
|
||||
test("one.example", &Record{Version: "TLSRPTv1", RUAs: [][]string{{"mailto:tlsrpt@basic.example"}}}, nil)
|
||||
test("multiple.example", nil, ErrMultipleRecords)
|
||||
test("absent.example", nil, ErrNoRecord)
|
||||
test("other.example", nil, ErrNoRecord)
|
||||
test("malformed.example", nil, ErrRecordSyntax)
|
||||
test("temperror.example", nil, ErrDNS)
|
||||
}
|
226
tlsrpt/parse.go
Normal file
226
tlsrpt/parse.go
Normal file
@ -0,0 +1,226 @@
|
||||
package tlsrpt
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Extension is an additional key/value pair for a TLSRPT record.
|
||||
type Extension struct {
|
||||
Key string
|
||||
Value string
|
||||
}
|
||||
|
||||
// Record is a parsed TLSRPT record, to be served under "_smtp._tls.<domain>".
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// v=TLSRPTv1; rua=mailto:tlsrpt@mox.example;
|
||||
type Record struct {
|
||||
Version string // "TLSRPTv1", for "v=".
|
||||
RUAs [][]string // Aggregate reporting URI, for "rua=". "rua=" can occur multiple times, each can be a list. Must be URL-encoded strings, with ",", "!" and ";" encoded.
|
||||
Extensions []Extension
|
||||
}
|
||||
|
||||
// String returns a string or use as a TLSRPT DNS TXT record.
|
||||
func (r Record) String() string {
|
||||
b := &strings.Builder{}
|
||||
fmt.Fprint(b, "v="+r.Version)
|
||||
for _, rua := range r.RUAs {
|
||||
fmt.Fprint(b, "; rua="+strings.Join(rua, ","))
|
||||
}
|
||||
for _, p := range r.Extensions {
|
||||
fmt.Fprint(b, "; "+p.Key+"="+p.Value)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
type parseErr string
|
||||
|
||||
func (e parseErr) Error() string {
|
||||
return string(e)
|
||||
}
|
||||
|
||||
var _ error = parseErr("")
|
||||
|
||||
// ParseRecord parses a TLSRPT record.
|
||||
func ParseRecord(txt string) (record *Record, istlsrpt bool, err error) {
|
||||
defer func() {
|
||||
x := recover()
|
||||
if x == nil {
|
||||
return
|
||||
}
|
||||
if xerr, ok := x.(parseErr); ok {
|
||||
record = nil
|
||||
err = fmt.Errorf("%w: %s", ErrRecordSyntax, xerr)
|
||||
return
|
||||
}
|
||||
panic(x)
|
||||
}()
|
||||
|
||||
p := newParser(txt)
|
||||
|
||||
record = &Record{
|
||||
Version: "TLSRPTv1",
|
||||
}
|
||||
|
||||
p.xtake("v=TLSRPTv1")
|
||||
p.xdelim()
|
||||
istlsrpt = true
|
||||
for {
|
||||
k := p.xkey()
|
||||
p.xtake("=")
|
||||
// note: duplicates are allowed.
|
||||
switch k {
|
||||
case "rua":
|
||||
record.RUAs = append(record.RUAs, p.xruas())
|
||||
default:
|
||||
v := p.xvalue()
|
||||
record.Extensions = append(record.Extensions, Extension{k, v})
|
||||
}
|
||||
if !p.delim() || p.empty() {
|
||||
break
|
||||
}
|
||||
}
|
||||
if !p.empty() {
|
||||
p.xerrorf("leftover chars")
|
||||
}
|
||||
if record.RUAs == nil {
|
||||
p.xerrorf("missing rua")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
type parser struct {
|
||||
s string
|
||||
o int
|
||||
}
|
||||
|
||||
func newParser(s string) *parser {
|
||||
return &parser{s: s}
|
||||
}
|
||||
|
||||
func (p *parser) xerrorf(format string, args ...any) {
|
||||
msg := fmt.Sprintf(format, args...)
|
||||
if p.o < len(p.s) {
|
||||
msg += fmt.Sprintf(" (remain %q)", p.s[p.o:])
|
||||
}
|
||||
panic(parseErr(msg))
|
||||
}
|
||||
|
||||
func (p *parser) xtake(s string) string {
|
||||
if !p.prefix(s) {
|
||||
p.xerrorf("expected %q", s)
|
||||
}
|
||||
p.o += len(s)
|
||||
return s
|
||||
}
|
||||
|
||||
func (p *parser) xdelim() {
|
||||
if !p.delim() {
|
||||
p.xerrorf("expected semicolon")
|
||||
}
|
||||
}
|
||||
|
||||
func (p *parser) xtaken(n int) string {
|
||||
r := p.s[p.o : p.o+n]
|
||||
p.o += n
|
||||
return r
|
||||
}
|
||||
|
||||
func (p *parser) prefix(s string) bool {
|
||||
return strings.HasPrefix(p.s[p.o:], s)
|
||||
}
|
||||
|
||||
func (p *parser) take(s string) bool {
|
||||
if p.prefix(s) {
|
||||
p.o += len(s)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (p *parser) xtakefn1(fn func(rune, int) bool) string {
|
||||
for i, b := range p.s[p.o:] {
|
||||
if !fn(b, i) {
|
||||
if i == 0 {
|
||||
p.xerrorf("expected at least one char")
|
||||
}
|
||||
return p.xtaken(i)
|
||||
}
|
||||
}
|
||||
if p.empty() {
|
||||
p.xerrorf("expected at least 1 char")
|
||||
}
|
||||
return p.xtaken(len(p.s) - p.o)
|
||||
}
|
||||
|
||||
// ../rfc/8460:368
|
||||
func (p *parser) xkey() string {
|
||||
return p.xtakefn1(func(b rune, i int) bool {
|
||||
return i < 32 && (b >= 'a' && b <= 'z' || b >= 'A' && b <= 'Z' || b >= '0' && b <= '9' || (i > 0 && b == '_' || b == '-' || b == '.'))
|
||||
})
|
||||
}
|
||||
|
||||
// ../rfc/8460:371
|
||||
func (p *parser) xvalue() string {
|
||||
return p.xtakefn1(func(b rune, i int) bool {
|
||||
return b > ' ' && b < 0x7f && b != '=' && b != ';'
|
||||
})
|
||||
}
|
||||
|
||||
// ../rfc/8460:399
|
||||
func (p *parser) delim() bool {
|
||||
o := p.o
|
||||
e := len(p.s)
|
||||
for o < e && (p.s[o] == ' ' || p.s[o] == '\t') {
|
||||
o++
|
||||
}
|
||||
if o >= e || p.s[o] != ';' {
|
||||
return false
|
||||
}
|
||||
o++
|
||||
for o < e && (p.s[o] == ' ' || p.s[o] == '\t') {
|
||||
o++
|
||||
}
|
||||
p.o = o
|
||||
return true
|
||||
}
|
||||
|
||||
func (p *parser) empty() bool {
|
||||
return p.o >= len(p.s)
|
||||
}
|
||||
|
||||
func (p *parser) wsp() {
|
||||
for p.o < len(p.s) && (p.s[p.o] == ' ' || p.s[p.o] == '\t') {
|
||||
p.o++
|
||||
}
|
||||
}
|
||||
|
||||
// ../rfc/8460:358
|
||||
func (p *parser) xruas() []string {
|
||||
l := []string{p.xuri()}
|
||||
p.wsp()
|
||||
for p.take(",") {
|
||||
p.wsp()
|
||||
l = append(l, p.xuri())
|
||||
p.wsp()
|
||||
}
|
||||
return l
|
||||
}
|
||||
|
||||
// ../rfc/8460:360
|
||||
func (p *parser) xuri() string {
|
||||
v := p.xtakefn1(func(b rune, i int) bool {
|
||||
return b != ',' && b != '!' && b != ' ' && b != '\t' && b != ';'
|
||||
})
|
||||
u, err := url.Parse(v)
|
||||
if err != nil {
|
||||
p.xerrorf("parsing uri %q: %s", v, err)
|
||||
}
|
||||
if u.Scheme == "" {
|
||||
p.xerrorf("missing scheme in uri")
|
||||
}
|
||||
return v
|
||||
}
|
83
tlsrpt/parse_test.go
Normal file
83
tlsrpt/parse_test.go
Normal file
@ -0,0 +1,83 @@
|
||||
package tlsrpt
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRecord(t *testing.T) {
|
||||
good := func(txt string, want Record) {
|
||||
t.Helper()
|
||||
r, _, err := ParseRecord(txt)
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %s", err)
|
||||
}
|
||||
if !reflect.DeepEqual(r, &want) {
|
||||
t.Fatalf("want %v, got %v", want, *r)
|
||||
}
|
||||
}
|
||||
|
||||
bad := func(txt string) {
|
||||
t.Helper()
|
||||
r, _, err := ParseRecord(txt)
|
||||
if err == nil {
|
||||
t.Fatalf("parse, expected error, got record %v", r)
|
||||
}
|
||||
}
|
||||
|
||||
good("v=TLSRPTv1; rua=mailto:tlsrpt@mox.example;", Record{Version: "TLSRPTv1", RUAs: [][]string{{"mailto:tlsrpt@mox.example"}}})
|
||||
good("v=TLSRPTv1; rua=mailto:tlsrpt@mox.example , \t\t https://mox.example/tlsrpt ", Record{Version: "TLSRPTv1", RUAs: [][]string{{"mailto:tlsrpt@mox.example", "https://mox.example/tlsrpt"}}})
|
||||
good("v=TLSRPTv1; rua=mailto:tlsrpt@mox.example; ext=yes", Record{Version: "TLSRPTv1", RUAs: [][]string{{"mailto:tlsrpt@mox.example"}}, Extensions: []Extension{{"ext", "yes"}}})
|
||||
good("v=TLSRPTv1 ; rua=mailto:x@x.example; rua=mailto:y@x.example", Record{Version: "TLSRPTv1", RUAs: [][]string{{"mailto:x@x.example"}, {"mailto:y@x.example"}}})
|
||||
|
||||
bad("v=TLSRPTv0")
|
||||
bad("v=TLSRPTv10")
|
||||
bad("v=TLSRPTv2")
|
||||
bad("v=TLSRPTv1") // missing rua
|
||||
bad("v=TLSRPTv1;") // missing rua
|
||||
bad("v=TLSRPTv1; ext=1") // missing rua
|
||||
bad("v=TLSRPTv1; rua=") // empty rua
|
||||
bad("v=TLSRPTv1; rua=noscheme")
|
||||
bad("v=TLSRPTv1; rua=,, ,") // empty uris
|
||||
bad("v=TLSRPTv1; rua=mailto:x@x.example; more=") // empty value in extension
|
||||
bad("v=TLSRPTv1; rua=mailto:x@x.example; a12345678901234567890123456789012=1") // extension name too long
|
||||
bad("v=TLSRPTv1; rua=mailto:x@x.example; 1%=a") // invalid extension name
|
||||
bad("v=TLSRPTv1; rua=mailto:x@x.example; test==") // invalid extension name
|
||||
bad("v=TLSRPTv1; rua=mailto:x@x.example;;") // additional semicolon
|
||||
bad("v=TLSRPTv1; rua=mailto:x@x.example other") // trailing characters.
|
||||
bad("v=TLSRPTv1; rua=http://bad/%") // bad URI
|
||||
|
||||
const want = `v=TLSRPTv1; rua=mailto:x@mox.example; more=a; ext=2`
|
||||
record := Record{Version: "TLSRPTv1", RUAs: [][]string{{"mailto:x@mox.example"}}, Extensions: []Extension{{"more", "a"}, {"ext", "2"}}}
|
||||
got := record.String()
|
||||
if got != want {
|
||||
t.Fatalf("record string, got %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func FuzzParseRecord(f *testing.F) {
|
||||
f.Add("v=TLSRPTv1; rua=mailto:tlsrpt@mox.example;")
|
||||
f.Add("v=TLSRPTv1; rua=mailto:tlsrpt@mox.example , \t\t https://mox.example/tlsrpt ")
|
||||
f.Add("v=TLSRPTv1; rua=mailto:tlsrpt@mox.example; ext=yes")
|
||||
|
||||
f.Add("v=TLSRPTv0")
|
||||
f.Add("v=TLSRPTv10")
|
||||
f.Add("v=TLSRPTv2")
|
||||
f.Add("v=TLSRPTv1") // missing rua
|
||||
f.Add("v=TLSRPTv1;") // missing rua
|
||||
f.Add("v=TLSRPTv1; ext=1") // missing rua
|
||||
f.Add("v=TLSRPTv1; rua=") // empty rua
|
||||
f.Add("v=TLSRPTv1; rua=noscheme")
|
||||
f.Add("v=TLSRPTv1; rua=,, ,") // empty uris
|
||||
f.Add("v=TLSRPTv1; rua=mailto:x@x.example; more=") // empty value in extension
|
||||
f.Add("v=TLSRPTv1; rua=mailto:x@x.example; a12345678901234567890123456789012=1") // extension name too long
|
||||
f.Add("v=TLSRPTv1; rua=mailto:x@x.example; 1%=a") // invalid extension name
|
||||
f.Add("v=TLSRPTv1; rua=mailto:x@x.example; test==") // invalid extension name
|
||||
f.Add("v=TLSRPTv1; rua=mailto:x@x.example;;") // additional semicolon
|
||||
f.Fuzz(func(t *testing.T, s string) {
|
||||
r, _, err := ParseRecord(s)
|
||||
if err == nil {
|
||||
_ = r.String()
|
||||
}
|
||||
})
|
||||
}
|
153
tlsrpt/report.go
Normal file
153
tlsrpt/report.go
Normal file
@ -0,0 +1,153 @@
|
||||
package tlsrpt
|
||||
|
||||
import (
|
||||
"compress/gzip"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mjl-/mox/message"
|
||||
"github.com/mjl-/mox/moxio"
|
||||
)
|
||||
|
||||
var ErrNoReport = errors.New("no tlsrpt report found")
|
||||
|
||||
// ../rfc/8460:628
|
||||
|
||||
// Report is a TLSRPT report, transmitted in JSON format.
|
||||
type Report struct {
|
||||
OrganizationName string `json:"organization-name"`
|
||||
DateRange TLSRPTDateRange `json:"date-range"`
|
||||
ContactInfo string `json:"contact-info"` // Email address.
|
||||
ReportID string `json:"report-id"`
|
||||
Policies []Result `json:"policies"`
|
||||
}
|
||||
|
||||
// note: with TLSRPT prefix to prevent clash in sherpadoc types.
|
||||
type TLSRPTDateRange struct {
|
||||
Start time.Time `json:"start-datetime"`
|
||||
End time.Time `json:"end-datetime"`
|
||||
}
|
||||
|
||||
type Result struct {
|
||||
Policy ResultPolicy `json:"policy"`
|
||||
Summary Summary `json:"summary"`
|
||||
FailureDetails []FailureDetails `json:"failure-details"`
|
||||
}
|
||||
|
||||
type ResultPolicy struct {
|
||||
Type string `json:"policy-type"`
|
||||
String []string `json:"policy-string"`
|
||||
Domain string `json:"policy-domain"`
|
||||
MXHost []string `json:"mx-host"` // Example in RFC has errata, it originally was a single string. ../rfc/8460-eid6241 ../rfc/8460:1779
|
||||
}
|
||||
|
||||
type Summary struct {
|
||||
TotalSuccessfulSessionCount int64 `json:"total-successful-session-count"`
|
||||
TotalFailureSessionCount int64 `json:"total-failure-session-count"`
|
||||
}
|
||||
|
||||
// ResultType represents a TLS error.
|
||||
type ResultType string
|
||||
|
||||
// ../rfc/8460:1377
|
||||
// https://www.iana.org/assignments/starttls-validation-result-types/starttls-validation-result-types.xhtml
|
||||
|
||||
const (
|
||||
ResultSTARTTLSNotSupported ResultType = "starttls-not-supported"
|
||||
ResultCertificateHostMismatch ResultType = "certificate-host-mismatch"
|
||||
ResultCertificateExpired ResultType = "certificate-expired"
|
||||
ResultTLSAInvalid ResultType = "tlsa-invalid"
|
||||
ResultDNSSECInvalid ResultType = "dnssec-invalid"
|
||||
ResultDANERequired ResultType = "dane-required"
|
||||
ResultCertificateNotTrusted ResultType = "certificate-not-trusted"
|
||||
ResultSTSPolicyInvalid ResultType = "sts-policy-invalid"
|
||||
ResultSTSWebPKIInvalid ResultType = "sts-webpki-invalid"
|
||||
ResultValidationFailure ResultType = "validation-failure" // Other error.
|
||||
ResultSTSPolicyFetch ResultType = "sts-policy-fetch-error"
|
||||
)
|
||||
|
||||
type FailureDetails struct {
|
||||
ResultType ResultType `json:"result-type"`
|
||||
SendingMTAIP string `json:"sending-mta-ip"`
|
||||
ReceivingMXHostname string `json:"receiving-mx-hostname"`
|
||||
ReceivingMXHelo string `json:"receiving-mx-helo"`
|
||||
ReceivingIP string `json:"receiving-ip"`
|
||||
FailedSessionCount int64 `json:"failed-session-count"`
|
||||
AdditionalInformation string `json:"additional-information"`
|
||||
FailureReasonCode string `json:"failure-reason-code"`
|
||||
}
|
||||
|
||||
// Parse parses a Report.
|
||||
// The maximum size is 20MB.
|
||||
func Parse(r io.Reader) (*Report, error) {
|
||||
r = &moxio.LimitReader{R: r, Limit: 20 * 1024 * 1024}
|
||||
var report Report
|
||||
if err := json.NewDecoder(r).Decode(&report); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// note: there may be leftover data, we ignore it.
|
||||
return &report, nil
|
||||
}
|
||||
|
||||
// ParseMessage parses a Report from a mail message.
|
||||
// The maximum size of the message is 15MB, the maximum size of the
|
||||
// decompressed report is 20MB.
|
||||
func ParseMessage(r io.ReaderAt) (*Report, error) {
|
||||
// ../rfc/8460:905
|
||||
p, err := message.Parse(&moxio.LimitAtReader{R: r, Limit: 15 * 1024 * 1024})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parsing mail message: %s", err)
|
||||
}
|
||||
|
||||
// Using multipart appears optional, and similar to DMARC someone may decide to
|
||||
// send it like that, so accept a report if it's the entire message.
|
||||
const allow = true
|
||||
return parseMessageReport(p, allow)
|
||||
}
|
||||
|
||||
func parseMessageReport(p message.Part, allow bool) (*Report, error) {
|
||||
if p.MediaType != "MULTIPART" {
|
||||
if !allow {
|
||||
return nil, ErrNoReport
|
||||
}
|
||||
return parseReport(p)
|
||||
}
|
||||
|
||||
for {
|
||||
sp, err := p.ParseNextPart()
|
||||
if err == io.EOF {
|
||||
return nil, ErrNoReport
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if p.MediaSubType == "REPORT" && p.ContentTypeParams["report-type"] != "tlsrpt" {
|
||||
return nil, fmt.Errorf("unknown report-type parameter %q", p.ContentTypeParams["report-type"])
|
||||
}
|
||||
report, err := parseMessageReport(*sp, p.MediaSubType == "REPORT")
|
||||
if err == ErrNoReport {
|
||||
continue
|
||||
} else if err != nil || report != nil {
|
||||
return report, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func parseReport(p message.Part) (*Report, error) {
|
||||
mt := strings.ToLower(p.MediaType + "/" + p.MediaSubType)
|
||||
switch mt {
|
||||
case "application/tlsrpt+json":
|
||||
return Parse(p.Reader())
|
||||
case "application/tlsrpt+gzip":
|
||||
gzr, err := gzip.NewReader(p.Reader())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decoding gzip TLSRPT report: %s", err)
|
||||
}
|
||||
return Parse(gzr)
|
||||
}
|
||||
return nil, ErrNoReport
|
||||
}
|
149
tlsrpt/report_test.go
Normal file
149
tlsrpt/report_test.go
Normal file
@ -0,0 +1,149 @@
|
||||
package tlsrpt
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const reportJSON = `{
|
||||
"organization-name": "Company-X",
|
||||
"date-range": {
|
||||
"start-datetime": "2016-04-01T00:00:00Z",
|
||||
"end-datetime": "2016-04-01T23:59:59Z"
|
||||
},
|
||||
"contact-info": "sts-reporting@company-x.example",
|
||||
"report-id": "5065427c-23d3-47ca-b6e0-946ea0e8c4be",
|
||||
"policies": [{
|
||||
"policy": {
|
||||
"policy-type": "sts",
|
||||
"policy-string": ["version: STSv1","mode: testing",
|
||||
"mx: *.mail.company-y.example","max_age: 86400"],
|
||||
"policy-domain": "company-y.example",
|
||||
"mx-host": ["*.mail.company-y.example"]
|
||||
},
|
||||
"summary": {
|
||||
"total-successful-session-count": 5326,
|
||||
"total-failure-session-count": 303
|
||||
},
|
||||
"failure-details": [{
|
||||
"result-type": "certificate-expired",
|
||||
"sending-mta-ip": "2001:db8:abcd:0012::1",
|
||||
"receiving-mx-hostname": "mx1.mail.company-y.example",
|
||||
"failed-session-count": 100
|
||||
}, {
|
||||
"result-type": "starttls-not-supported",
|
||||
"sending-mta-ip": "2001:db8:abcd:0013::1",
|
||||
"receiving-mx-hostname": "mx2.mail.company-y.example",
|
||||
"receiving-ip": "203.0.113.56",
|
||||
"failed-session-count": 200,
|
||||
"additional-information": "https://reports.company-x.example/report_info ? id = 5065427 c - 23 d3# StarttlsNotSupported "
|
||||
}, {
|
||||
"result-type": "validation-failure",
|
||||
"sending-mta-ip": "198.51.100.62",
|
||||
"receiving-ip": "203.0.113.58",
|
||||
"receiving-mx-hostname": "mx-backup.mail.company-y.example",
|
||||
"failed-session-count": 3,
|
||||
"failure-reason-code": "X509_V_ERR_PROXY_PATH_LENGTH_EXCEEDED"
|
||||
}]
|
||||
}]
|
||||
}`
|
||||
|
||||
// ../rfc/8460:1015
|
||||
var tlsrptMessage = strings.ReplaceAll(`From: tlsrpt@mail.sender.example.com
|
||||
Date: Fri, May 09 2017 16:54:30 -0800
|
||||
To: mts-sts-tlsrpt@example.net
|
||||
Subject: Report Domain: example.net
|
||||
Submitter: mail.sender.example.com
|
||||
Report-ID: <735ff.e317+bf22029@example.net>
|
||||
TLS-Report-Domain: example.net
|
||||
TLS-Report-Submitter: mail.sender.example.com
|
||||
MIME-Version: 1.0
|
||||
Content-Type: multipart/report; report-type="tlsrpt";
|
||||
boundary="----=_NextPart_000_024E_01CC9B0A.AFE54C00"
|
||||
Content-Language: en-us
|
||||
|
||||
This is a multipart message in MIME format.
|
||||
|
||||
------=_NextPart_000_024E_01CC9B0A.AFE54C00
|
||||
Content-Type: text/plain; charset="us-ascii"
|
||||
Content-Transfer-Encoding: 7bit
|
||||
|
||||
This is an aggregate TLS report from mail.sender.example.com
|
||||
|
||||
------=_NextPart_000_024E_01CC9B0A.AFE54C00
|
||||
Content-Type: application/tlsrpt+json
|
||||
Content-Transfer-Encoding: 8bit
|
||||
Content-Disposition: attachment;
|
||||
filename="mail.sender.example!example.com!1013662812!1013749130.json.gz"
|
||||
|
||||
`+reportJSON+`
|
||||
|
||||
------=_NextPart_000_024E_01CC9B0A.AFE54C00--
|
||||
`, "\n", "\r\n")
|
||||
|
||||
// Message without multipart.
|
||||
var tlsrptMessage2 = strings.ReplaceAll(`From: tlsrpt@mail.sender.example.com
|
||||
To: mts-sts-tlsrpt@example.net
|
||||
Subject: Report Domain: example.net
|
||||
Report-ID: <735ff.e317+bf22029@example.net>
|
||||
TLS-Report-Domain: example.net
|
||||
TLS-Report-Submitter: mail.sender.example.com
|
||||
MIME-Version: 1.0
|
||||
Content-Type: application/tlsrpt+json
|
||||
Content-Transfer-Encoding: 8bit
|
||||
Content-Disposition: attachment;
|
||||
filename="mail.sender.example!example.com!1013662812!1013749130.json.gz"
|
||||
|
||||
`+reportJSON+`
|
||||
`, "\n", "\r\n")
|
||||
|
||||
func TestReport(t *testing.T) {
|
||||
// ../rfc/8460:1756
|
||||
|
||||
var report Report
|
||||
dec := json.NewDecoder(strings.NewReader(reportJSON))
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(&report); err != nil {
|
||||
t.Fatalf("parsing report: %s", err)
|
||||
}
|
||||
|
||||
if _, err := ParseMessage(strings.NewReader(tlsrptMessage)); err != nil {
|
||||
t.Fatalf("parsing TLSRPT from message: %s", err)
|
||||
}
|
||||
|
||||
if _, err := ParseMessage(strings.NewReader(tlsrptMessage2)); err != nil {
|
||||
t.Fatalf("parsing TLSRPT from message: %s", err)
|
||||
}
|
||||
|
||||
if _, err := ParseMessage(strings.NewReader(strings.ReplaceAll(tlsrptMessage, "multipart/report", "multipart/related"))); err != ErrNoReport {
|
||||
t.Fatalf("got err %v, expected ErrNoReport", err)
|
||||
}
|
||||
|
||||
if _, err := ParseMessage(strings.NewReader(strings.ReplaceAll(tlsrptMessage, "application/tlsrpt+json", "application/json"))); err != ErrNoReport {
|
||||
t.Fatalf("got err %v, expected ErrNoReport", err)
|
||||
}
|
||||
|
||||
files, err := os.ReadDir("../testdata/tlsreports")
|
||||
if err != nil {
|
||||
t.Fatalf("listing reports: %s", err)
|
||||
}
|
||||
for _, file := range files {
|
||||
f, err := os.Open("../testdata/tlsreports/" + file.Name())
|
||||
if err != nil {
|
||||
t.Fatalf("open %q: %s", file, err)
|
||||
}
|
||||
if _, err := ParseMessage(f); err != nil {
|
||||
t.Fatalf("parsing TLSRPT from message %q: %s", file.Name(), err)
|
||||
}
|
||||
f.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func FuzzParseMessage(f *testing.F) {
|
||||
f.Add(tlsrptMessage)
|
||||
f.Fuzz(func(t *testing.T, s string) {
|
||||
ParseMessage(strings.NewReader(s))
|
||||
})
|
||||
}
|
Reference in New Issue
Block a user