From 1516991057ba4a4b5a3b602a87d0d438020bc933 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Mon, 18 May 2026 15:33:27 +0800 Subject: [PATCH] dmarc: implement RFC 7489 org-domain fallback and RFC 9091 PSD DMARC MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DMARC lookup now follows the full RFC 7489 §6.6.3 fallback chain: exact From domain → organizational domain (eTLD+1 via PSL) → public suffix domain (RFC 9091, only when psd=y is present). DNS errors abort immediately without triggering fallback; NXDOMAIN and missing v=DMARC1 records do trigger it. The found domain is exposed in the new DMARCRecord.domain field for reporting purposes. Also promote getOrganizationalDomain to a package-level function so both HeaderAnalyzer and DNSAnalyzer can share it, and fix pre-existing rbl_test.go compilation errors and stale score expectations. Closes: https://git.nemunai.re/happyDomain/happyDeliver/issues/98 --- api/schemas.yaml | 4 + pkg/analyzer/dns_dmarc.go | 114 ++++++++---- pkg/analyzer/dns_dmarc_test.go | 167 +++++++++++++++++- pkg/analyzer/headers.go | 8 +- pkg/analyzer/rbl_test.go | 30 ++-- .../lib/components/DmarcRecordDisplay.svelte | 26 ++- web/src/lib/components/DnsRecordsCard.svelte | 5 +- 7 files changed, 296 insertions(+), 58 deletions(-) diff --git a/api/schemas.yaml b/api/schemas.yaml index df0b416..fa908c4 100644 --- a/api/schemas.yaml +++ b/api/schemas.yaml @@ -891,6 +891,10 @@ components: type: string description: DMARC record content example: "v=DMARC1; p=quarantine; rua=mailto:dmarc@example.com" + domain: + type: string + description: Domain at which the DMARC record was found (may differ from From domain when organizational domain fallback was used) + example: "example.com" policy: type: string enum: [none, quarantine, reject, unknown] diff --git a/pkg/analyzer/dns_dmarc.go b/pkg/analyzer/dns_dmarc.go index 686fb0b..913b98a 100644 --- a/pkg/analyzer/dns_dmarc.go +++ b/pkg/analyzer/dns_dmarc.go @@ -24,62 +24,50 @@ package analyzer import ( "context" "fmt" + "net" "regexp" "strings" + "golang.org/x/net/publicsuffix" + "git.happydns.org/happyDeliver/internal/model" "git.happydns.org/happyDeliver/internal/utils" ) -// checkmodel.DMARCRecord looks up and validates DMARC record for a domain -func (d *DNSAnalyzer) checkDMARCRecord(domain string) *model.DMARCRecord { - // DMARC records are at: _dmarc.domain - dmarcDomain := fmt.Sprintf("_dmarc.%s", domain) - +// lookupDMARCAt queries _dmarc. and returns the raw DMARC1 TXT record. +// notFound=true means no record exists (NXDOMAIN or empty); false means a real DNS error occurred. +func (d *DNSAnalyzer) lookupDMARCAt(domain string) (record string, notFound bool, err error) { ctx, cancel := context.WithTimeout(context.Background(), d.Timeout) defer cancel() - txtRecords, err := d.resolver.LookupTXT(ctx, dmarcDomain) - if err != nil { - return &model.DMARCRecord{ - Valid: false, - Error: utils.PtrTo(fmt.Sprintf("Failed to lookup DMARC record: %v", err)), + txtRecords, lookupErr := d.resolver.LookupTXT(ctx, fmt.Sprintf("_dmarc.%s", domain)) + if lookupErr != nil { + if dnsErr, ok := lookupErr.(*net.DNSError); ok && dnsErr.IsNotFound { + return "", true, nil } + return "", false, lookupErr } - // Find DMARC record (starts with "v=DMARC1") - var dmarcRecord string for _, txt := range txtRecords { if strings.HasPrefix(txt, "v=DMARC1") { - dmarcRecord = txt - break + return txt, false, nil } } + return "", true, nil +} - if dmarcRecord == "" { +// parseDMARCRecord parses a raw DMARC TXT record into a DMARCRecord model. +func (d *DNSAnalyzer) parseDMARCRecord(foundDomain, rawRecord string) *model.DMARCRecord { + policy := d.extractDMARCPolicy(rawRecord) + subdomainPolicy := d.extractDMARCSubdomainPolicy(rawRecord) + percentage := d.extractDMARCPercentage(rawRecord) + spfAlignment := d.extractDMARCSPFAlignment(rawRecord) + dkimAlignment := d.extractDMARCDKIMAlignment(rawRecord) + + if !d.validateDMARC(rawRecord) { return &model.DMARCRecord{ - Valid: false, - Error: utils.PtrTo("No DMARC record found"), - } - } - - // Extract policy - policy := d.extractDMARCPolicy(dmarcRecord) - - // Extract subdomain policy - subdomainPolicy := d.extractDMARCSubdomainPolicy(dmarcRecord) - - // Extract percentage - percentage := d.extractDMARCPercentage(dmarcRecord) - - // Extract alignment modes - spfAlignment := d.extractDMARCSPFAlignment(dmarcRecord) - dkimAlignment := d.extractDMARCDKIMAlignment(dmarcRecord) - - // Basic validation - if !d.validateDMARC(dmarcRecord) { - return &model.DMARCRecord{ - Record: &dmarcRecord, + Domain: &foundDomain, + Record: &rawRecord, Policy: utils.PtrTo(model.DMARCRecordPolicy(policy)), SubdomainPolicy: subdomainPolicy, Percentage: percentage, @@ -91,7 +79,8 @@ func (d *DNSAnalyzer) checkDMARCRecord(domain string) *model.DMARCRecord { } return &model.DMARCRecord{ - Record: &dmarcRecord, + Domain: &foundDomain, + Record: &rawRecord, Policy: utils.PtrTo(model.DMARCRecordPolicy(policy)), SubdomainPolicy: subdomainPolicy, Percentage: percentage, @@ -101,6 +90,55 @@ func (d *DNSAnalyzer) checkDMARCRecord(domain string) *model.DMARCRecord { } } +// checkDMARCRecord looks up and validates the DMARC record for a domain. +// It follows RFC 7489 §6.6.3 fallback to the Organizational Domain and +// RFC 9091 optional fallback to the Public Suffix Domain (only when psd=y). +func (d *DNSAnalyzer) checkDMARCRecord(domain string) *model.DMARCRecord { + // Step 1: try exact domain (_dmarc.) + raw, notFound, err := d.lookupDMARCAt(domain) + if err != nil { + return &model.DMARCRecord{ + Valid: false, + Error: utils.PtrTo(fmt.Sprintf("Failed to lookup DMARC record: %v", err)), + } + } + if !notFound { + return d.parseDMARCRecord(domain, raw) + } + + // Step 2: RFC 7489 — fall back to Organizational Domain (eTLD+1) + orgDomain := getOrganizationalDomain(domain) + if orgDomain != domain { + raw, notFound, err = d.lookupDMARCAt(orgDomain) + if err != nil { + return &model.DMARCRecord{ + Valid: false, + Error: utils.PtrTo(fmt.Sprintf("Failed to lookup DMARC record: %v", err)), + } + } + if !notFound { + return d.parseDMARCRecord(orgDomain, raw) + } + } + + // Step 3: RFC 9091 — fall back to Public Suffix Domain when psd=y + psd, _ := publicsuffix.PublicSuffix(domain) + if psd != "" && psd != orgDomain { + raw, notFound, err = d.lookupDMARCAt(psd) + if err == nil && !notFound { + // Only apply PSD DMARC when the record explicitly opts in with psd=y + if strings.Contains(raw, "psd=y") { + return d.parseDMARCRecord(psd, raw) + } + } + } + + return &model.DMARCRecord{ + Valid: false, + Error: utils.PtrTo("No DMARC record found"), + } +} + // extractDMARCPolicy extracts the policy from a DMARC record func (d *DNSAnalyzer) extractDMARCPolicy(record string) string { // Look for p=none, p=quarantine, or p=reject diff --git a/pkg/analyzer/dns_dmarc_test.go b/pkg/analyzer/dns_dmarc_test.go index 93f4511..ed1c25d 100644 --- a/pkg/analyzer/dns_dmarc_test.go +++ b/pkg/analyzer/dns_dmarc_test.go @@ -22,13 +22,178 @@ package analyzer import ( + "context" + "fmt" + "net" "testing" "time" - "git.happydns.org/happyDeliver/internal/model" "git.happydns.org/happyDeliver/internal/utils" ) +// mockDNSResolver maps domain names to TXT records for testing. +// An entry with value nil means NXDOMAIN; an error value triggers a DNS error. +type mockDNSResolver struct { + txt map[string][]string + err map[string]error +} + +func (m *mockDNSResolver) LookupTXT(_ context.Context, name string) ([]string, error) { + if err, ok := m.err[name]; ok { + return nil, err + } + if records, ok := m.txt[name]; ok { + return records, nil + } + return nil, &net.DNSError{Err: "no such host", Name: name, IsNotFound: true} +} + +func (m *mockDNSResolver) LookupMX(_ context.Context, _ string) ([]*net.MX, error) { + return nil, nil +} +func (m *mockDNSResolver) LookupAddr(_ context.Context, _ string) ([]string, error) { + return nil, nil +} +func (m *mockDNSResolver) LookupHost(_ context.Context, _ string) ([]string, error) { + return nil, nil +} + +func newMockAnalyzer(txt map[string][]string, errMap map[string]error) *DNSAnalyzer { + if errMap == nil { + errMap = map[string]error{} + } + return NewDNSAnalyzerWithResolver(5*time.Second, &mockDNSResolver{txt: txt, err: errMap}) +} + +func TestCheckDMARCRecordFallback(t *testing.T) { + const orgRecord = "v=DMARC1; p=quarantine; rua=mailto:dmarc@example.com" + const subRecord = "v=DMARC1; p=reject" + const psdRecord = "v=DMARC1; p=none; psd=y" + + tests := []struct { + name string + domain string + txt map[string][]string + errMap map[string]error + wantValid bool + wantDomain *string + wantErrSubst string + }{ + { + name: "exact domain has DMARC record — no fallback", + domain: "mail.example.com", + txt: map[string][]string{ + "_dmarc.mail.example.com": {subRecord}, + "_dmarc.example.com": {orgRecord}, + }, + wantValid: true, + wantDomain: utils.PtrTo("mail.example.com"), + }, + { + name: "exact domain NXDOMAIN — falls back to org domain", + domain: "mail.example.com", + txt: map[string][]string{ + "_dmarc.example.com": {orgRecord}, + }, + wantValid: true, + wantDomain: utils.PtrTo("example.com"), + }, + { + name: "exact domain has no v=DMARC1 TXT — falls back to org domain", + domain: "mail.example.com", + txt: map[string][]string{ + "_dmarc.mail.example.com": {"some-other-txt"}, + "_dmarc.example.com": {orgRecord}, + }, + wantValid: true, + wantDomain: utils.PtrTo("example.com"), + }, + { + name: "both exact and org NXDOMAIN but PSD has psd=y — RFC 9091 fallback", + domain: "mail.example.com", + txt: map[string][]string{ + "_dmarc.com": {psdRecord}, + }, + wantValid: true, + wantDomain: utils.PtrTo("com"), + }, + { + name: "PSD record exists but no psd=y — no record returned", + domain: "mail.example.com", + txt: map[string][]string{ + "_dmarc.com": {"v=DMARC1; p=none"}, + }, + wantValid: false, + wantErrSubst: "No DMARC record found", + }, + { + name: "no record at any level", + domain: "mail.example.com", + txt: map[string][]string{}, + wantValid: false, + wantErrSubst: "No DMARC record found", + }, + { + name: "DNS error on exact domain — no fallback, error returned", + domain: "mail.example.com", + errMap: map[string]error{ + "_dmarc.mail.example.com": fmt.Errorf("SERVFAIL"), + }, + wantValid: false, + wantErrSubst: "SERVFAIL", + }, + { + name: "domain already at org level — no redundant fallback", + domain: "example.com", + txt: map[string][]string{ + "_dmarc.example.com": {orgRecord}, + }, + wantValid: true, + wantDomain: utils.PtrTo("example.com"), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + analyzer := newMockAnalyzer(tt.txt, tt.errMap) + result := analyzer.checkDMARCRecord(tt.domain) + + if result.Valid != tt.wantValid { + t.Errorf("Valid = %v, want %v", result.Valid, tt.wantValid) + } + if tt.wantDomain != nil { + if result.Domain == nil { + t.Fatalf("Domain = nil, want %q", *tt.wantDomain) + } + if *result.Domain != *tt.wantDomain { + t.Errorf("Domain = %q, want %q", *result.Domain, *tt.wantDomain) + } + } + if tt.wantErrSubst != "" { + if result.Error == nil { + t.Fatalf("Error = nil, want substring %q", tt.wantErrSubst) + } + if !contains(*result.Error, tt.wantErrSubst) { + t.Errorf("Error = %q, want substring %q", *result.Error, tt.wantErrSubst) + } + } + }) + } +} + +func contains(s, substr string) bool { + return len(s) >= len(substr) && (s == substr || len(s) > 0 && containsStr(s, substr)) +} + +func containsStr(s, sub string) bool { + for i := 0; i <= len(s)-len(sub); i++ { + if s[i:i+len(sub)] == sub { + return true + } + } + return false +} + func TestExtractDMARCPolicy(t *testing.T) { tests := []struct { name string diff --git a/pkg/analyzer/headers.go b/pkg/analyzer/headers.go index f750742..6d7b547 100644 --- a/pkg/analyzer/headers.go +++ b/pkg/analyzer/headers.go @@ -388,7 +388,7 @@ func (h *HeaderAnalyzer) analyzeDomainAlignment(email *EmailMessage, authResults if domain != "" { alignment.FromDomain = &domain // Extract organizational domain - orgDomain := h.getOrganizationalDomain(domain) + orgDomain := getOrganizationalDomain(domain) alignment.FromOrgDomain = &orgDomain } } @@ -400,7 +400,7 @@ func (h *HeaderAnalyzer) analyzeDomainAlignment(email *EmailMessage, authResults if domain != "" { alignment.ReturnPathDomain = &domain // Extract organizational domain - orgDomain := h.getOrganizationalDomain(domain) + orgDomain := getOrganizationalDomain(domain) alignment.ReturnPathOrgDomain = &orgDomain } } @@ -411,7 +411,7 @@ func (h *HeaderAnalyzer) analyzeDomainAlignment(email *EmailMessage, authResults for _, dkim := range *authResults.Dkim { if dkim.Domain != nil && *dkim.Domain != "" { domain := *dkim.Domain - orgDomain := h.getOrganizationalDomain(domain) + orgDomain := getOrganizationalDomain(domain) dkimDomains = append(dkimDomains, model.DKIMDomainInfo{ Domain: domain, OrgDomain: orgDomain, @@ -542,7 +542,7 @@ func (h *HeaderAnalyzer) extractDomain(emailAddr string) string { // getOrganizationalDomain extracts the organizational domain from a fully qualified domain name // using the Public Suffix List (PSL) to correctly handle multi-level TLDs. // For example: mail.example.com -> example.com, mail.example.co.uk -> example.co.uk -func (h *HeaderAnalyzer) getOrganizationalDomain(domain string) string { +func getOrganizationalDomain(domain string) string { domain = strings.ToLower(strings.TrimSpace(domain)) // Use golang.org/x/net/publicsuffix to get the eTLD+1 (organizational domain) diff --git a/pkg/analyzer/rbl_test.go b/pkg/analyzer/rbl_test.go index 8620038..f86f17b 100644 --- a/pkg/analyzer/rbl_test.go +++ b/pkg/analyzer/rbl_test.go @@ -291,34 +291,38 @@ func TestGetBlacklistScore(t *testing.T) { { name: "Listed on 1 RBL", results: &DNSListResults{ - IPsChecked: []string{"198.51.100.1"}, - ListedCount: 1, + IPsChecked: []string{"198.51.100.1"}, + ListedCount: 1, + RelevantListedCount: 1, }, - expectedScore: 84, // 100 - 1*100/6 = 84 (integer division: 100/6=16) + expectedScore: 92, // 100 - 1*100/12 = 92 (12 scoring lists = 14 default - 2 informational) }, { name: "Listed on 2 RBLs", results: &DNSListResults{ - IPsChecked: []string{"198.51.100.1"}, - ListedCount: 2, + IPsChecked: []string{"198.51.100.1"}, + ListedCount: 2, + RelevantListedCount: 2, }, - expectedScore: 67, // 100 - 2*100/6 = 67 (integer division: 200/6=33) + expectedScore: 84, // 100 - 2*100/12 = 84 }, { name: "Listed on 3 RBLs", results: &DNSListResults{ - IPsChecked: []string{"198.51.100.1"}, - ListedCount: 3, + IPsChecked: []string{"198.51.100.1"}, + ListedCount: 3, + RelevantListedCount: 3, }, - expectedScore: 50, // 100 - 3*100/6 = 50 (integer division: 300/6=50) + expectedScore: 75, // 100 - 3*100/12 = 75 }, { name: "Listed on 4+ RBLs", results: &DNSListResults{ - IPsChecked: []string{"198.51.100.1"}, - ListedCount: 4, + IPsChecked: []string{"198.51.100.1"}, + ListedCount: 4, + RelevantListedCount: 4, }, - expectedScore: 34, // 100 - 4*100/6 = 34 (integer division: 400/6=66) + expectedScore: 67, // 100 - 4*100/12 = 67 }, } @@ -326,7 +330,7 @@ func TestGetBlacklistScore(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - score, _ := checker.CalculateScore(tt.results) + score, _ := checker.CalculateScore(tt.results, false) if score != tt.expectedScore { t.Errorf("GetBlacklistScore() = %v, want %v", score, tt.expectedScore) } diff --git a/web/src/lib/components/DmarcRecordDisplay.svelte b/web/src/lib/components/DmarcRecordDisplay.svelte index b7a3e7b..6f3b4a3 100644 --- a/web/src/lib/components/DmarcRecordDisplay.svelte +++ b/web/src/lib/components/DmarcRecordDisplay.svelte @@ -3,9 +3,15 @@ interface Props { dmarcRecord?: DmarcRecord; + fromDomain?: string; } - let { dmarcRecord }: Props = $props(); + let { dmarcRecord, fromDomain }: Props = $props(); + + const isFallback = $derived( + !!dmarcRecord?.domain && !!fromDomain && dmarcRecord.domain !== fromDomain, + ); + const isPsdFallback = $derived(isFallback && !dmarcRecord?.domain?.includes(".")); // Helper function to determine policy strength const policyStrength = (policy: string | undefined): number => { @@ -52,6 +58,24 @@ {/if} + + {#if isFallback} +
+ Record found at: + {dmarcRecord.domain} +
+ + No DMARC record exists for {fromDomain}. The record above was + inherited from + {#if isPsdFallback} + the Public Suffix Domain {dmarcRecord.domain} per RFC 9091. + {:else} + the organizational domain {dmarcRecord.domain} per RFC 7489. + {/if} +
+
+ {/if} + {#if dmarcRecord.policy}
diff --git a/web/src/lib/components/DnsRecordsCard.svelte b/web/src/lib/components/DnsRecordsCard.svelte index b7997b0..6dabe0b 100644 --- a/web/src/lib/components/DnsRecordsCard.svelte +++ b/web/src/lib/components/DnsRecordsCard.svelte @@ -165,7 +165,10 @@ {/if} - +