Add end-to-end tests for TLS RPT report ingestion and WebSocket event handling
- Implemented tests for ingesting TLS RPT reports from emails and ensuring they are stored correctly in the database. - Added tests to verify that the same report can be ingested multiple times without duplication. - Created tests for backfilling reports that were previously delivered to the inbox. - Developed WebSocket event handling tests to ensure events are correctly routed to subscribers based on mailbox ownership. - Included tests for the mail API to validate session handling, mailbox isolation, and error mapping. - Ensured that the API rejects unauthorized requests and handles malformed JSON gracefully.
This commit is contained in:
1 parent
880dced9e4
commit
f330c4324e
28 files changed
+2438
-6
No files matched your search
@@ -1,6 +1,7 @@
|
||||
package entity
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"encoding/xml"
|
||||
@@ -55,10 +56,16 @@ type dmarcXmlReport struct {
|
||||
} `xml:"record"`
|
||||
}
|
||||
|
||||
// ParseDmarcXmlReport parses raw XML or gzipped XML DMARC aggregate report.
|
||||
// ParseDmarcXmlReport parses a DMARC aggregate report: raw XML, gzipped XML,
|
||||
// or XML inside a zip archive.
|
||||
//
|
||||
// Zip matters as much as gzip. Microsoft and Yahoo send .zip, and between them
|
||||
// they account for a large share of the reports any domain receives; handling
|
||||
// gzip alone meant those reports never parsed at all.
|
||||
func ParseDmarcXmlReport(payload []byte) (*DmarcReport, error) {
|
||||
data := payload
|
||||
if isGzip(payload) {
|
||||
switch {
|
||||
case isGzip(payload):
|
||||
gz, err := gzip.NewReader(bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("gzip reader: %w", err)
|
||||
@@ -69,6 +76,12 @@ func ParseDmarcXmlReport(payload []byte) (*DmarcReport, error) {
|
||||
return nil, fmt.Errorf("decompress gzip: %w", err)
|
||||
}
|
||||
data = decompressed
|
||||
case isZip(payload):
|
||||
decompressed, err := firstFileInZip(payload)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
data = decompressed
|
||||
}
|
||||
|
||||
var parsed dmarcXmlReport
|
||||
@@ -101,3 +114,41 @@ func ParseDmarcXmlReport(payload []byte) (*DmarcReport, error) {
|
||||
func isGzip(payload []byte) bool {
|
||||
return len(payload) >= 2 && payload[0] == 0x1f && payload[1] == 0x8b
|
||||
}
|
||||
|
||||
// isZip reports whether the payload begins with a local file header. The
|
||||
// empty-archive and spanned-archive signatures are deliberately not accepted:
|
||||
// neither carries a report.
|
||||
func isZip(payload []byte) bool {
|
||||
return len(payload) >= 4 && payload[0] == 'P' && payload[1] == 'K' &&
|
||||
payload[2] == 0x03 && payload[3] == 0x04
|
||||
}
|
||||
|
||||
// firstFileInZip returns the contents of the first regular file in the
|
||||
// archive. An aggregate report is one XML document per archive, so there is
|
||||
// nothing to choose between; directories are skipped so an archive that
|
||||
// carries one cannot be mistaken for an empty one.
|
||||
func firstFileInZip(payload []byte) ([]byte, error) {
|
||||
reader, err := zip.NewReader(bytes.NewReader(payload), int64(len(payload)))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("zip reader: %w", err)
|
||||
}
|
||||
for _, file := range reader.File {
|
||||
if file.FileInfo().IsDir() {
|
||||
continue
|
||||
}
|
||||
handle, err := file.Open()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open %s in zip: %w", file.Name, err)
|
||||
}
|
||||
defer handle.Close()
|
||||
// Bounded so a zip bomb cannot exhaust this process: an aggregate
|
||||
// report is XML in the tens of kilobytes, and 64 MiB is far past any
|
||||
// legitimate one.
|
||||
decompressed, err := io.ReadAll(io.LimitReader(handle, 64<<20))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decompress %s in zip: %w", file.Name, err)
|
||||
}
|
||||
return decompressed, nil
|
||||
}
|
||||
return nil, fmt.Errorf("zip archive contains no report")
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package entity
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A real DMARC aggregate report, trimmed to the fields the parser reads.
|
||||
const dmarcAggregateXML = `<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<feedback>
|
||||
<report_metadata>
|
||||
<org_name>Yahoo</org_name>
|
||||
<report_id>1234567890</report_id>
|
||||
<date_range><begin>1787097600</begin><end>1787183999</end></date_range>
|
||||
</report_metadata>
|
||||
<policy_published>
|
||||
<domain>example.test</domain>
|
||||
<p>quarantine</p>
|
||||
</policy_published>
|
||||
<record>
|
||||
<row>
|
||||
<source_ip>203.0.113.10</source_ip>
|
||||
<count>3</count>
|
||||
<policy_evaluated><disposition>none</disposition><dkim>pass</dkim><spf>pass</spf></policy_evaluated>
|
||||
</row>
|
||||
<identifiers><header_from>example.test</header_from></identifiers>
|
||||
</record>
|
||||
</feedback>`
|
||||
|
||||
// Microsoft and Yahoo send aggregate reports as .zip, not .gz. The parser
|
||||
// handled gzip only, so every report from those two failed to parse - and
|
||||
// between them they are a large share of the reports any domain receives.
|
||||
func TestDmarcReportParsesZippedPayload(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
zw := zip.NewWriter(&buf)
|
||||
f, err := zw.Create("example.test!1787097600!1787183999.xml")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := f.Write([]byte(dmarcAggregateXML)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := zw.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
report, err := ParseDmarcXmlReport(buf.Bytes())
|
||||
if err != nil {
|
||||
t.Fatalf("parse zipped dmarc report: %v", err)
|
||||
}
|
||||
if report.OrgName != "Yahoo" {
|
||||
t.Errorf("org name is %q, want Yahoo", report.OrgName)
|
||||
}
|
||||
if report.Domain != "example.test" {
|
||||
t.Errorf("domain is %q, want example.test", report.Domain)
|
||||
}
|
||||
if len(report.Records) != 1 {
|
||||
t.Fatalf("want 1 record, got %d", len(report.Records))
|
||||
}
|
||||
}
|
||||
|
||||
// Plain XML must keep working exactly as before.
|
||||
func TestDmarcReportStillParsesPlainXml(t *testing.T) {
|
||||
report, err := ParseDmarcXmlReport([]byte(dmarcAggregateXML))
|
||||
if err != nil {
|
||||
t.Fatalf("parse plain xml: %v", err)
|
||||
}
|
||||
if report.OrgName != "Yahoo" {
|
||||
t.Errorf("org name is %q", report.OrgName)
|
||||
}
|
||||
}
|
||||
|
||||
// An empty archive has no report in it and must be reported as such rather
|
||||
// than panicking on a missing first file.
|
||||
func TestDmarcReportRejectsEmptyZip(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
zw := zip.NewWriter(&buf)
|
||||
if err := zw.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := ParseDmarcXmlReport(buf.Bytes()); err == nil {
|
||||
t.Error("an empty zip should not parse as a report")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user