mirror of
https://github.com/mjl-/mox.git
synced 2025-07-14 19:34:36 +03:00
mox!
This commit is contained in:
157
dmarcrpt/feedback.go
Normal file
157
dmarcrpt/feedback.go
Normal file
@ -0,0 +1,157 @@
|
||||
package dmarcrpt
|
||||
|
||||
// Initially generated by xsdgen, then modified.
|
||||
|
||||
// Feedback is the top-level XML field returned.
|
||||
type Feedback struct {
|
||||
Version string `xml:"version"`
|
||||
ReportMetadata ReportMetadata `xml:"report_metadata"`
|
||||
PolicyPublished PolicyPublished `xml:"policy_published"`
|
||||
Records []ReportRecord `xml:"record"`
|
||||
}
|
||||
|
||||
type ReportMetadata struct {
|
||||
OrgName string `xml:"org_name"`
|
||||
Email string `xml:"email"`
|
||||
ExtraContactInfo string `xml:"extra_contact_info,omitempty"`
|
||||
ReportID string `xml:"report_id"`
|
||||
DateRange DateRange `xml:"date_range"`
|
||||
Errors []string `xml:"error,omitempty"`
|
||||
}
|
||||
|
||||
type DateRange struct {
|
||||
Begin int64 `xml:"begin"`
|
||||
End int64 `xml:"end"`
|
||||
}
|
||||
|
||||
// PolicyPublished is the policy as found in DNS for the domain.
|
||||
type PolicyPublished struct {
|
||||
Domain string `xml:"domain"`
|
||||
ADKIM Alignment `xml:"adkim,omitempty"`
|
||||
ASPF Alignment `xml:"aspf,omitempty"`
|
||||
Policy Disposition `xml:"p"`
|
||||
SubdomainPolicy Disposition `xml:"sp"`
|
||||
Percentage int `xml:"pct"`
|
||||
ReportingOptions string `xml:"fo"`
|
||||
}
|
||||
|
||||
// Alignment is the identifier alignment.
|
||||
type Alignment string
|
||||
|
||||
const (
|
||||
AlignmentRelaxed Alignment = "r" // Subdomains match the DMARC from-domain.
|
||||
AlignmentStrict Alignment = "s" // Only exact from-domain match.
|
||||
)
|
||||
|
||||
// Disposition is the requested action for a DMARC fail as specified in the
|
||||
// DMARC policy in DNS.
|
||||
type Disposition string
|
||||
|
||||
const (
|
||||
DispositionNone Disposition = "none"
|
||||
DispositionQuarantine Disposition = "quarantine"
|
||||
DispositionReject Disposition = "reject"
|
||||
)
|
||||
|
||||
type ReportRecord struct {
|
||||
Row Row `xml:"row"`
|
||||
Identifiers Identifiers `xml:"identifiers"`
|
||||
AuthResults AuthResults `xml:"auth_results"`
|
||||
}
|
||||
|
||||
type Row struct {
|
||||
// SourceIP must match the pattern ((1?[0-9]?[0-9]|2[0-4][0-9]|25[0-5]).){3}
|
||||
// (1?[0-9]?[0-9]|2[0-4][0-9]|25[0-5])|
|
||||
// ([A-Fa-f0-9]{1,4}:){7}[A-Fa-f0-9]{1,4}
|
||||
SourceIP string `xml:"source_ip"`
|
||||
Count int `xml:"count"`
|
||||
PolicyEvaluated PolicyEvaluated `xml:"policy_evaluated"`
|
||||
}
|
||||
|
||||
type PolicyEvaluated struct {
|
||||
Disposition Disposition `xml:"disposition"`
|
||||
DKIM DMARCResult `xml:"dkim"`
|
||||
SPF DMARCResult `xml:"spf"`
|
||||
Reasons []PolicyOverrideReason `xml:"reason,omitempty"`
|
||||
}
|
||||
|
||||
// DMARCResult is the final validation and alignment verdict for SPF and DKIM.
|
||||
type DMARCResult string
|
||||
|
||||
const (
|
||||
DMARCPass DMARCResult = "pass"
|
||||
DMARCFail DMARCResult = "fail"
|
||||
)
|
||||
|
||||
type PolicyOverrideReason struct {
|
||||
Type PolicyOverride `xml:"type"`
|
||||
Comment string `xml:"comment,omitempty"`
|
||||
}
|
||||
|
||||
// PolicyOverride is a reason the requested DMARC policy from the DNS record
|
||||
// was not applied.
|
||||
type PolicyOverride string
|
||||
|
||||
const (
|
||||
PolicyOverrideForwarded PolicyOverride = "forwarded"
|
||||
PolicyOverrideSampledOut PolicyOverride = "sampled_out"
|
||||
PolicyOverrideTrustedForwarder PolicyOverride = "trusted_forwarder"
|
||||
PolicyOverrideMailingList PolicyOverride = "mailing_list"
|
||||
PolicyOverrideLocalPolicy PolicyOverride = "local_policy"
|
||||
PolicyOverrideOther PolicyOverride = "other"
|
||||
)
|
||||
|
||||
type Identifiers struct {
|
||||
EnvelopeTo string `xml:"envelope_to,omitempty"`
|
||||
EnvelopeFrom string `xml:"envelope_from"`
|
||||
HeaderFrom string `xml:"header_from"`
|
||||
}
|
||||
|
||||
type AuthResults struct {
|
||||
DKIM []DKIMAuthResult `xml:"dkim,omitempty"`
|
||||
SPF []SPFAuthResult `xml:"spf"`
|
||||
}
|
||||
|
||||
type DKIMAuthResult struct {
|
||||
Domain string `xml:"domain"`
|
||||
Selector string `xml:"selector,omitempty"`
|
||||
Result DKIMResult `xml:"result"`
|
||||
HumanResult string `xml:"human_result,omitempty"`
|
||||
}
|
||||
|
||||
type DKIMResult string
|
||||
|
||||
const (
|
||||
DKIMNone DKIMResult = "none"
|
||||
DKIMPass DKIMResult = "pass"
|
||||
DKIMFail DKIMResult = "fail"
|
||||
DKIMPolicy DKIMResult = "policy"
|
||||
DKIMNeutral DKIMResult = "neutral"
|
||||
DKIMTemperror DKIMResult = "temperror"
|
||||
DKIMPermerror DKIMResult = "permerror"
|
||||
)
|
||||
|
||||
type SPFAuthResult struct {
|
||||
Domain string `xml:"domain"`
|
||||
Scope SPFDomainScope `xml:"scope"`
|
||||
Result SPFResult `xml:"result"`
|
||||
}
|
||||
|
||||
type SPFDomainScope string
|
||||
|
||||
const (
|
||||
SPFDomainScopeHelo SPFDomainScope = "helo" // SMTP EHLO
|
||||
SPFDomainScopeMailFrom SPFDomainScope = "mfrom" // SMTP "MAIL FROM".
|
||||
)
|
||||
|
||||
type SPFResult string
|
||||
|
||||
const (
|
||||
SPFNone SPFResult = "none"
|
||||
SPFNeutral SPFResult = "neutral"
|
||||
SPFPass SPFResult = "pass"
|
||||
SPFFail SPFResult = "fail"
|
||||
SPFSoftfail SPFResult = "softfail"
|
||||
SPFTemperror SPFResult = "temperror"
|
||||
SPFPermerror SPFResult = "permerror"
|
||||
)
|
124
dmarcrpt/parse.go
Normal file
124
dmarcrpt/parse.go
Normal file
@ -0,0 +1,124 @@
|
||||
// Package dmarcrpt parses DMARC aggregate feedback reports.
|
||||
package dmarcrpt
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/mjl-/mox/message"
|
||||
"github.com/mjl-/mox/moxio"
|
||||
)
|
||||
|
||||
var ErrNoReport = errors.New("no dmarc report found in message")
|
||||
|
||||
// ParseReport parses an XML aggregate feedback report.
|
||||
// The maximum report size is 20MB.
|
||||
func ParseReport(r io.Reader) (*Feedback, error) {
|
||||
r = &moxio.LimitReader{R: r, Limit: 20 * 1024 * 1024}
|
||||
var feedback Feedback
|
||||
d := xml.NewDecoder(r)
|
||||
if err := d.Decode(&feedback); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &feedback, nil
|
||||
}
|
||||
|
||||
// ParseMessageReport parses an aggregate feedback report from a mail message. The
|
||||
// maximum message size is 15MB, the maximum report size after decompression is
|
||||
// 20MB.
|
||||
func ParseMessageReport(r io.ReaderAt) (*Feedback, error) {
|
||||
// ../rfc/7489:1801
|
||||
p, err := message.Parse(&moxio.LimitAtReader{R: r, Limit: 15 * 1024 * 1024})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parsing mail message: %s", err)
|
||||
}
|
||||
|
||||
return parseMessageReport(p)
|
||||
}
|
||||
|
||||
func parseMessageReport(p message.Part) (*Feedback, error) {
|
||||
// Pretty much any mime structure is allowed. ../rfc/7489:1861
|
||||
// In practice, some parties will send the report as the only (non-multipart)
|
||||
// content of the message.
|
||||
|
||||
if p.MediaType != "MULTIPART" {
|
||||
return parseReport(p)
|
||||
}
|
||||
|
||||
for {
|
||||
sp, err := p.ParseNextPart()
|
||||
if err == io.EOF {
|
||||
return nil, ErrNoReport
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
report, err := parseMessageReport(*sp)
|
||||
if err == ErrNoReport {
|
||||
continue
|
||||
} else if err != nil || report != nil {
|
||||
return report, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func parseReport(p message.Part) (*Feedback, error) {
|
||||
ct := strings.ToLower(p.MediaType + "/" + p.MediaSubType)
|
||||
r := p.Reader()
|
||||
|
||||
// If no (useful) content-type is set, try to detect it.
|
||||
if ct == "" || ct == "application/octect-stream" {
|
||||
data := make([]byte, 512)
|
||||
n, err := io.ReadFull(r, data)
|
||||
if err == io.EOF {
|
||||
return nil, ErrNoReport
|
||||
} else if err != nil && err != io.ErrUnexpectedEOF {
|
||||
return nil, fmt.Errorf("reading application/octet-stream for content-type detection: %v", err)
|
||||
}
|
||||
data = data[:n]
|
||||
ct = http.DetectContentType(data)
|
||||
r = io.MultiReader(bytes.NewReader(data), r)
|
||||
}
|
||||
|
||||
switch ct {
|
||||
case "application/zip":
|
||||
// Google sends messages with direct application/zip content-type.
|
||||
return parseZip(r)
|
||||
case "application/gzip":
|
||||
gzr, err := gzip.NewReader(r)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decoding gzip xml report: %s", err)
|
||||
}
|
||||
return ParseReport(gzr)
|
||||
case "text/xml", "application/xml":
|
||||
return ParseReport(r)
|
||||
}
|
||||
return nil, ErrNoReport
|
||||
}
|
||||
|
||||
func parseZip(r io.Reader) (*Feedback, error) {
|
||||
buf, err := io.ReadAll(r)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading feedback: %s", err)
|
||||
}
|
||||
zr, err := zip.NewReader(bytes.NewReader(buf), int64(len(buf)))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parsing zip file: %s", err)
|
||||
}
|
||||
if len(zr.File) != 1 {
|
||||
return nil, fmt.Errorf("zip contains %d files, expected 1", len(zr.File))
|
||||
}
|
||||
f, err := zr.File[0].Open()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("opening file in zip: %s", err)
|
||||
}
|
||||
defer f.Close()
|
||||
return ParseReport(f)
|
||||
}
|
179
dmarcrpt/parse_test.go
Normal file
179
dmarcrpt/parse_test.go
Normal file
@ -0,0 +1,179 @@
|
||||
package dmarcrpt
|
||||
|
||||
import (
|
||||
"os"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const reportExample = `<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<feedback>
|
||||
<report_metadata>
|
||||
<org_name>google.com</org_name>
|
||||
<email>noreply-dmarc-support@google.com</email>
|
||||
<extra_contact_info>https://support.google.com/a/answer/2466580</extra_contact_info>
|
||||
<report_id>10051505501689795560</report_id>
|
||||
<date_range>
|
||||
<begin>1596412800</begin>
|
||||
<end>1596499199</end>
|
||||
</date_range>
|
||||
</report_metadata>
|
||||
<policy_published>
|
||||
<domain>example.org</domain>
|
||||
<adkim>r</adkim>
|
||||
<aspf>r</aspf>
|
||||
<p>reject</p>
|
||||
<sp>reject</sp>
|
||||
<pct>100</pct>
|
||||
</policy_published>
|
||||
<record>
|
||||
<row>
|
||||
<source_ip>127.0.0.1</source_ip>
|
||||
<count>1</count>
|
||||
<policy_evaluated>
|
||||
<disposition>none</disposition>
|
||||
<dkim>pass</dkim>
|
||||
<spf>pass</spf>
|
||||
</policy_evaluated>
|
||||
</row>
|
||||
<identifiers>
|
||||
<header_from>example.org</header_from>
|
||||
</identifiers>
|
||||
<auth_results>
|
||||
<dkim>
|
||||
<domain>example.org</domain>
|
||||
<result>pass</result>
|
||||
<selector>example</selector>
|
||||
</dkim>
|
||||
<spf>
|
||||
<domain>example.org</domain>
|
||||
<result>pass</result>
|
||||
</spf>
|
||||
</auth_results>
|
||||
</record>
|
||||
</feedback>
|
||||
`
|
||||
|
||||
func TestParseReport(t *testing.T) {
|
||||
var expect = &Feedback{
|
||||
ReportMetadata: ReportMetadata{
|
||||
OrgName: "google.com",
|
||||
Email: "noreply-dmarc-support@google.com",
|
||||
ExtraContactInfo: "https://support.google.com/a/answer/2466580",
|
||||
ReportID: "10051505501689795560",
|
||||
DateRange: DateRange{
|
||||
Begin: 1596412800,
|
||||
End: 1596499199,
|
||||
},
|
||||
},
|
||||
PolicyPublished: PolicyPublished{
|
||||
Domain: "example.org",
|
||||
ADKIM: "r",
|
||||
ASPF: "r",
|
||||
Policy: "reject",
|
||||
SubdomainPolicy: "reject",
|
||||
Percentage: 100,
|
||||
},
|
||||
Records: []ReportRecord{
|
||||
{
|
||||
Row: Row{
|
||||
SourceIP: "127.0.0.1",
|
||||
Count: 1,
|
||||
PolicyEvaluated: PolicyEvaluated{
|
||||
Disposition: DispositionNone,
|
||||
DKIM: DMARCPass,
|
||||
SPF: DMARCPass,
|
||||
},
|
||||
},
|
||||
Identifiers: Identifiers{
|
||||
HeaderFrom: "example.org",
|
||||
},
|
||||
AuthResults: AuthResults{
|
||||
DKIM: []DKIMAuthResult{
|
||||
{
|
||||
Domain: "example.org",
|
||||
Result: DKIMPass,
|
||||
Selector: "example",
|
||||
},
|
||||
},
|
||||
SPF: []SPFAuthResult{
|
||||
{
|
||||
Domain: "example.org",
|
||||
Result: SPFPass,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
feedback, err := ParseReport(strings.NewReader(reportExample))
|
||||
if err != nil {
|
||||
t.Fatalf("parsing report: %s", err)
|
||||
}
|
||||
if !reflect.DeepEqual(expect, feedback) {
|
||||
t.Fatalf("expected:\n%#v\ngot:\n%#v", expect, feedback)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMessageReport(t *testing.T) {
|
||||
const dir = "../testdata/dmarc-reports"
|
||||
files, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("listing dmarc report emails: %s", err)
|
||||
}
|
||||
|
||||
for _, file := range files {
|
||||
p := dir + "/" + file.Name()
|
||||
f, err := os.Open(p)
|
||||
if err != nil {
|
||||
t.Fatalf("open %q: %s", p, err)
|
||||
}
|
||||
_, err = ParseMessageReport(f)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseMessageReport: %q: %s", p, err)
|
||||
}
|
||||
f.Close()
|
||||
}
|
||||
|
||||
// No report in a non-multipart message.
|
||||
_, err = ParseMessageReport(strings.NewReader("From: <mjl@mox.example>\r\n\r\nNo report.\r\n"))
|
||||
if err != ErrNoReport {
|
||||
t.Fatalf("message without report, got err %#v, expected ErrNoreport", err)
|
||||
}
|
||||
|
||||
// No report in a multipart message.
|
||||
var multipartNoreport = strings.ReplaceAll(`From: <mjl@mox.example>
|
||||
To: <mjl@mox.example>
|
||||
Subject: Report Domain: mox.example Submitter: mail.mox.example
|
||||
MIME-Version: 1.0
|
||||
Content-Type: multipart/alternative; boundary="===============5735553800636657282=="
|
||||
|
||||
--===============5735553800636657282==
|
||||
Content-Type: text/plain
|
||||
MIME-Version: 1.0
|
||||
|
||||
test
|
||||
|
||||
--===============5735553800636657282==
|
||||
Content-Type: text/html
|
||||
MIME-Version: 1.0
|
||||
|
||||
<html></html>
|
||||
|
||||
--===============5735553800636657282==--
|
||||
`, "\n", "\r\n")
|
||||
_, err = ParseMessageReport(strings.NewReader(multipartNoreport))
|
||||
if err != ErrNoReport {
|
||||
t.Fatalf("message without report, got err %#v, expected ErrNoreport", err)
|
||||
}
|
||||
}
|
||||
|
||||
func FuzzParseReport(f *testing.F) {
|
||||
f.Add("")
|
||||
f.Add(reportExample)
|
||||
f.Fuzz(func(t *testing.T, s string) {
|
||||
ParseReport(strings.NewReader(s))
|
||||
})
|
||||
}
|
Reference in New Issue
Block a user