From 5b179e7b93ba8d277eca793699b2acccf8d74617 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Mon, 3 Nov 2025 14:58:48 +0700 Subject: [PATCH] Domain alignment checks for DKIM --- api/openapi.yaml | 20 +- pkg/analyzer/headers.go | 119 +++++++++-- pkg/analyzer/headers_test.go | 160 ++++++++++++++- pkg/analyzer/report.go | 2 +- .../lib/components/HeaderAnalysisCard.svelte | 192 +++++++++++++----- 5 files changed, 410 insertions(+), 83 deletions(-) diff --git a/api/openapi.yaml b/api/openapi.yaml index 25c1b90..8463007 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -664,6 +664,21 @@ components: description: Reverse DNS (PTR record) for the IP address example: "mail.example.com" + DKIMDomainInfo: + type: object + required: + - domain + - org_domain + properties: + domain: + type: string + description: DKIM signature domain + example: "mail.example.com" + org_domain: + type: string + description: Organizational domain extracted from DKIM domain (using Public Suffix List) + example: "example.com" + DomainAlignment: type: object properties: @@ -686,9 +701,8 @@ components: dkim_domains: type: array items: - type: string - description: Domains from DKIM signatures - example: ["example.com"] + $ref: '#/components/schemas/DKIMDomainInfo' + description: Domains from DKIM signatures with their organizational domains aligned: type: boolean description: Whether all domains align (strict alignment - exact match) diff --git a/pkg/analyzer/headers.go b/pkg/analyzer/headers.go index 7e65571..b7ff3bb 100644 --- a/pkg/analyzer/headers.go +++ b/pkg/analyzer/headers.go @@ -52,13 +52,14 @@ func (h *HeaderAnalyzer) CalculateHeaderScore(analysis *api.HeaderAnalysis) (int maxGrade := 6 headers := *analysis.Headers - // RP and From alignment (20 points) - if analysis.DomainAlignment.Aligned != nil && *analysis.DomainAlignment.Aligned { - score += 20 - } else if analysis.DomainAlignment.RelaxedAligned != nil && *analysis.DomainAlignment.RelaxedAligned { - score += 15 - } else { + // RP and From alignment (25 points) + if analysis.DomainAlignment.Aligned == nil || !*analysis.DomainAlignment.RelaxedAligned { + // Bad domain alignment, cap grade to C maxGrade -= 2 + } else if *analysis.DomainAlignment.Aligned { + score += 25 + } else if *analysis.DomainAlignment.RelaxedAligned { + score += 20 } // Check required headers (RFC 5322) - 30 points @@ -79,7 +80,7 @@ func (h *HeaderAnalyzer) CalculateHeaderScore(analysis *api.HeaderAnalysis) (int maxGrade = 1 } - // Check recommended headers (20 points) + // Check recommended headers (15 points) recommendedHeaders := []string{"subject", "to"} // Add reply-to when from is a no-reply address @@ -95,7 +96,7 @@ func (h *HeaderAnalyzer) CalculateHeaderScore(analysis *api.HeaderAnalysis) (int presentRecommended++ } } - score += presentRecommended * 20 / recommendedCount + score += presentRecommended * 15 / recommendedCount if presentRecommended < recommendedCount { maxGrade -= 1 @@ -235,7 +236,7 @@ func (h *HeaderAnalyzer) formatAddress(addr *mail.Address) string { } // GenerateHeaderAnalysis creates structured header analysis from email -func (h *HeaderAnalyzer) GenerateHeaderAnalysis(email *EmailMessage) *api.HeaderAnalysis { +func (h *HeaderAnalyzer) GenerateHeaderAnalysis(email *EmailMessage, authResults *api.AuthenticationResults) *api.HeaderAnalysis { if email == nil { return nil } @@ -281,7 +282,7 @@ func (h *HeaderAnalyzer) GenerateHeaderAnalysis(email *EmailMessage) *api.Header } // Domain alignment - domainAlignment := h.analyzeDomainAlignment(email) + domainAlignment := h.analyzeDomainAlignment(email, authResults) if domainAlignment != nil { analysis.DomainAlignment = domainAlignment } @@ -352,8 +353,8 @@ func (h *HeaderAnalyzer) checkHeader(email *EmailMessage, headerName string, imp return check } -// analyzeDomainAlignment checks domain alignment between headers -func (h *HeaderAnalyzer) analyzeDomainAlignment(email *EmailMessage) *api.DomainAlignment { +// analyzeDomainAlignment checks domain alignment between headers and DKIM signatures +func (h *HeaderAnalyzer) analyzeDomainAlignment(email *EmailMessage, authResults *api.AuthenticationResults) *api.DomainAlignment { alignment := &api.DomainAlignment{ Aligned: api.PtrTo(true), RelaxedAligned: api.PtrTo(true), @@ -383,14 +384,45 @@ func (h *HeaderAnalyzer) analyzeDomainAlignment(email *EmailMessage) *api.Domain } } + // Extract DKIM domains from authentication results + var dkimDomains []api.DKIMDomainInfo + if authResults != nil && authResults.Dkim != nil { + for _, dkim := range *authResults.Dkim { + if dkim.Domain != nil && *dkim.Domain != "" { + domain := *dkim.Domain + orgDomain := h.getOrganizationalDomain(domain) + dkimDomains = append(dkimDomains, api.DKIMDomainInfo{ + Domain: domain, + OrgDomain: orgDomain, + }) + } + } + } + if len(dkimDomains) > 0 { + alignment.DkimDomains = &dkimDomains + } + // Check alignment (strict and relaxed) issues := []string{} - if alignment.FromDomain != nil && alignment.ReturnPathDomain != nil { + + // hasReturnPath and hasDKIM track whether we have these fields to check + hasReturnPath := alignment.FromDomain != nil && alignment.ReturnPathDomain != nil + hasDKIM := alignment.FromDomain != nil && len(dkimDomains) > 0 + + // If neither Return-Path nor DKIM is present, keep default alignment (true) + // Otherwise, at least one must be aligned for overall alignment to be true + strictAligned := !hasReturnPath && !hasDKIM + relaxedAligned := !hasReturnPath && !hasDKIM + + // Check Return-Path alignment + rpStrictAligned := false + rpRelaxedAligned := false + if hasReturnPath { fromDomain := *alignment.FromDomain rpDomain := *alignment.ReturnPathDomain // Strict alignment: exact match (case-insensitive) - strictAligned := strings.EqualFold(fromDomain, rpDomain) + rpStrictAligned = strings.EqualFold(fromDomain, rpDomain) // Relaxed alignment: organizational domain match var fromOrgDomain, rpOrgDomain string @@ -400,20 +432,67 @@ func (h *HeaderAnalyzer) analyzeDomainAlignment(email *EmailMessage) *api.Domain if alignment.ReturnPathOrgDomain != nil { rpOrgDomain = *alignment.ReturnPathOrgDomain } - relaxedAligned := strings.EqualFold(fromOrgDomain, rpOrgDomain) + rpRelaxedAligned = strings.EqualFold(fromOrgDomain, rpOrgDomain) - *alignment.Aligned = strictAligned - *alignment.RelaxedAligned = relaxedAligned - - if !strictAligned { - if relaxedAligned { + if !rpStrictAligned { + if rpRelaxedAligned { issues = append(issues, fmt.Sprintf("Return-Path domain (%s) does not exactly match From domain (%s), but satisfies relaxed alignment (organizational domain: %s)", rpDomain, fromDomain, fromOrgDomain)) } else { issues = append(issues, fmt.Sprintf("Return-Path domain (%s) does not match From domain (%s) - neither strict nor relaxed alignment", rpDomain, fromDomain)) } } + + strictAligned = rpStrictAligned + relaxedAligned = rpRelaxedAligned } + // Check DKIM alignment + dkimStrictAligned := false + dkimRelaxedAligned := false + if hasDKIM { + fromDomain := *alignment.FromDomain + var fromOrgDomain string + if alignment.FromOrgDomain != nil { + fromOrgDomain = *alignment.FromOrgDomain + } + + for _, dkimDomain := range dkimDomains { + // Check strict alignment for this DKIM signature + if strings.EqualFold(fromDomain, dkimDomain.Domain) { + dkimStrictAligned = true + } + + // Check relaxed alignment for this DKIM signature + if strings.EqualFold(fromOrgDomain, dkimDomain.OrgDomain) { + dkimRelaxedAligned = true + } + } + + if !dkimStrictAligned && !dkimRelaxedAligned { + // List all DKIM domains that failed alignment + dkimDomainsList := []string{} + for _, dkimDomain := range dkimDomains { + dkimDomainsList = append(dkimDomainsList, dkimDomain.Domain) + } + issues = append(issues, fmt.Sprintf("DKIM signature domains (%s) do not align with From domain (%s) - neither strict nor relaxed alignment", strings.Join(dkimDomainsList, ", "), fromDomain)) + } else if !dkimStrictAligned && dkimRelaxedAligned { + // DKIM has relaxed alignment but not strict + issues = append(issues, fmt.Sprintf("DKIM signature domains satisfy relaxed alignment with From domain (%s) but not strict alignment (organizational domain: %s)", fromDomain, fromOrgDomain)) + } + + // Overall alignment requires at least one method (Return-Path OR DKIM) to be aligned + // For DMARC compliance, at least one of SPF or DKIM must be aligned + if dkimStrictAligned { + strictAligned = true + } + if dkimRelaxedAligned { + relaxedAligned = true + } + } + + *alignment.Aligned = strictAligned + *alignment.RelaxedAligned = relaxedAligned + if len(issues) > 0 { alignment.Issues = &issues } diff --git a/pkg/analyzer/headers_test.go b/pkg/analyzer/headers_test.go index 7896a5c..6a35d18 100644 --- a/pkg/analyzer/headers_test.go +++ b/pkg/analyzer/headers_test.go @@ -24,6 +24,7 @@ package analyzer import ( "net/mail" "net/textproto" + "strings" "testing" "git.happydns.org/happyDeliver/internal/api" @@ -110,7 +111,7 @@ func TestCalculateHeaderScore(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { // Generate header analysis first - analysis := analyzer.GenerateHeaderAnalysis(tt.email) + analysis := analyzer.GenerateHeaderAnalysis(tt.email, nil) score, _ := analyzer.CalculateHeaderScore(analysis) if score < tt.minScore || score > tt.maxScore { t.Errorf("CalculateHeaderScore() = %v, want between %v and %v", score, tt.minScore, tt.maxScore) @@ -360,7 +361,7 @@ func TestAnalyzeDomainAlignment(t *testing.T) { }), } - alignment := analyzer.analyzeDomainAlignment(email) + alignment := analyzer.analyzeDomainAlignment(email, nil) if alignment == nil { t.Fatal("Expected non-nil alignment") @@ -698,7 +699,7 @@ func TestGenerateHeaderAnalysis_WithReceivedChain(t *testing.T) { "from relay.example.com (relay.example.com [192.0.2.2]) by mail.example.com with SMTP id DEF456; Mon, 01 Jan 2024 11:59:00 +0000", } - analysis := analyzer.GenerateHeaderAnalysis(email) + analysis := analyzer.GenerateHeaderAnalysis(email, nil) if analysis == nil { t.Fatal("GenerateHeaderAnalysis returned nil") @@ -923,3 +924,156 @@ func equalStrPtr(a, b *string) bool { } return *a == *b } + +func TestAnalyzeDomainAlignment_WithDKIM(t *testing.T) { + tests := []struct { + name string + fromHeader string + returnPath string + dkimDomains []string + expectStrictAligned bool + expectRelaxedAligned bool + expectIssuesContain string + }{ + { + name: "DKIM strict alignment with From domain", + fromHeader: "sender@example.com", + returnPath: "", + dkimDomains: []string{"example.com"}, + expectStrictAligned: true, + expectRelaxedAligned: true, + expectIssuesContain: "", + }, + { + name: "DKIM relaxed alignment only", + fromHeader: "sender@mail.example.com", + returnPath: "", + dkimDomains: []string{"example.com"}, + expectStrictAligned: false, + expectRelaxedAligned: true, + expectIssuesContain: "relaxed alignment", + }, + { + name: "DKIM no alignment", + fromHeader: "sender@example.com", + returnPath: "", + dkimDomains: []string{"different.com"}, + expectStrictAligned: false, + expectRelaxedAligned: false, + expectIssuesContain: "do not align", + }, + { + name: "Multiple DKIM signatures - one aligns", + fromHeader: "sender@example.com", + returnPath: "", + dkimDomains: []string{"different.com", "example.com"}, + expectStrictAligned: true, + expectRelaxedAligned: true, + expectIssuesContain: "", + }, + { + name: "Return-Path misaligned but DKIM aligned", + fromHeader: "sender@example.com", + returnPath: "bounce@different.com", + dkimDomains: []string{"example.com"}, + expectStrictAligned: true, + expectRelaxedAligned: true, + expectIssuesContain: "Return-Path", + }, + { + name: "Return-Path aligned, no DKIM", + fromHeader: "sender@example.com", + returnPath: "bounce@example.com", + dkimDomains: []string{}, + expectStrictAligned: true, + expectRelaxedAligned: true, + expectIssuesContain: "", + }, + { + name: "Both Return-Path and DKIM misaligned", + fromHeader: "sender@example.com", + returnPath: "bounce@other.com", + dkimDomains: []string{"different.com"}, + expectStrictAligned: false, + expectRelaxedAligned: false, + expectIssuesContain: "do not", + }, + } + + analyzer := NewHeaderAnalyzer() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + email := &EmailMessage{ + Header: createHeaderWithFields(map[string]string{ + "From": tt.fromHeader, + "Return-Path": tt.returnPath, + }), + } + + // Create authentication results with DKIM signatures + var authResults *api.AuthenticationResults + if len(tt.dkimDomains) > 0 { + dkimResults := make([]api.AuthResult, 0, len(tt.dkimDomains)) + for _, domain := range tt.dkimDomains { + dkimResults = append(dkimResults, api.AuthResult{ + Result: api.AuthResultResultPass, + Domain: &domain, + }) + } + authResults = &api.AuthenticationResults{ + Dkim: &dkimResults, + } + } + + alignment := analyzer.analyzeDomainAlignment(email, authResults) + + if alignment == nil { + t.Fatal("Expected non-nil alignment") + } + + if alignment.Aligned == nil { + t.Fatal("Expected non-nil Aligned field") + } + + if *alignment.Aligned != tt.expectStrictAligned { + t.Errorf("Aligned = %v, want %v", *alignment.Aligned, tt.expectStrictAligned) + } + + if alignment.RelaxedAligned == nil { + t.Fatal("Expected non-nil RelaxedAligned field") + } + + if *alignment.RelaxedAligned != tt.expectRelaxedAligned { + t.Errorf("RelaxedAligned = %v, want %v", *alignment.RelaxedAligned, tt.expectRelaxedAligned) + } + + // Check DKIM domains are populated + if len(tt.dkimDomains) > 0 { + if alignment.DkimDomains == nil { + t.Error("Expected DkimDomains to be populated") + } else if len(*alignment.DkimDomains) != len(tt.dkimDomains) { + t.Errorf("Expected %d DKIM domains, got %d", len(tt.dkimDomains), len(*alignment.DkimDomains)) + } + } + + // Check issues contain expected string + if tt.expectIssuesContain != "" { + if alignment.Issues == nil || len(*alignment.Issues) == 0 { + t.Errorf("Expected issues to contain '%s', but no issues found", tt.expectIssuesContain) + } else { + found := false + for _, issue := range *alignment.Issues { + if strings.Contains(strings.ToLower(issue), strings.ToLower(tt.expectIssuesContain)) { + found = true + break + } + } + if !found { + t.Errorf("Expected issues to contain '%s', but found: %v", tt.expectIssuesContain, *alignment.Issues) + } + } + } + }) + } +} diff --git a/pkg/analyzer/report.go b/pkg/analyzer/report.go index a39a98a..39871fe 100644 --- a/pkg/analyzer/report.go +++ b/pkg/analyzer/report.go @@ -75,7 +75,7 @@ func (r *ReportGenerator) AnalyzeEmail(email *EmailMessage) *AnalysisResults { // Run all analyzers results.Authentication = r.authAnalyzer.AnalyzeAuthentication(email) - results.Headers = r.headerAnalyzer.GenerateHeaderAnalysis(email) + results.Headers = r.headerAnalyzer.GenerateHeaderAnalysis(email, results.Authentication) results.DNS = r.dnsAnalyzer.AnalyzeDNS(email, results.Authentication, results.Headers) results.RBL = r.rblChecker.CheckEmail(email) results.SpamAssassin = r.spamAnalyzer.AnalyzeSpamAssassin(email) diff --git a/web/src/lib/components/HeaderAnalysisCard.svelte b/web/src/lib/components/HeaderAnalysisCard.svelte index 306260e..3cfe287 100644 --- a/web/src/lib/components/HeaderAnalysisCard.svelte +++ b/web/src/lib/components/HeaderAnalysisCard.svelte @@ -66,68 +66,148 @@
-

- Domain alignment ensures that the visible "From" domain matches the domain used for authentication (Return-Path). Proper alignment is crucial for DMARC compliance and helps prevent email spoofing by verifying that the sender domain is consistent across all authentication layers. +

+ Domain alignment ensures that the visible "From" domain matches the domain used for authentication (Return-Path or DKIM signature). Proper alignment is crucial for DMARC compliance, regardless of the policy. It helps prevent email spoofing by verifying that the sender domain is consistent across all authentication layers. Only one of the following lines needs to pass.

-
-
- Strict Alignment -
- - - {headerAnalysis.domain_alignment.aligned ? 'Pass' : 'Fail'} - -
-
Exact domain match
+
+
+
+
+ SPF
-
- Relaxed Alignment -
- - - {headerAnalysis.domain_alignment.relaxed_aligned ? 'Pass' : 'Fail'} - -
-
Organizational domain match
-
-
- From Domain -
{headerAnalysis.domain_alignment.from_domain || '-'}
- {#if headerAnalysis.domain_alignment.from_org_domain && headerAnalysis.domain_alignment.from_org_domain !== headerAnalysis.domain_alignment.from_domain} -
Org: {headerAnalysis.domain_alignment.from_org_domain}
- {/if} -
-
- Return-Path Domain -
{headerAnalysis.domain_alignment.return_path_domain || '-'}
- {#if headerAnalysis.domain_alignment.return_path_org_domain && headerAnalysis.domain_alignment.return_path_org_domain !== headerAnalysis.domain_alignment.return_path_domain} -
Org: {headerAnalysis.domain_alignment.return_path_org_domain}
- {/if} -
-
- {#if headerAnalysis.domain_alignment.issues && headerAnalysis.domain_alignment.issues.length > 0} -
- {#each headerAnalysis.domain_alignment.issues as issue} -
- - {issue} +
+
+ Strict Alignment +
+ + + {headerAnalysis.domain_alignment.aligned ? 'Pass' : 'Fail'} +
- {/each} +
Exact domain match
+
+
+ Relaxed Alignment +
+ + + {headerAnalysis.domain_alignment.relaxed_aligned ? 'Pass' : 'Fail'} + +
+
Organizational domain match
+
+
+ From Domain +
{headerAnalysis.domain_alignment.from_domain || '-'}
+ {#if headerAnalysis.domain_alignment.from_org_domain && headerAnalysis.domain_alignment.from_org_domain !== headerAnalysis.domain_alignment.from_domain} +
Org: {headerAnalysis.domain_alignment.from_org_domain}
+ {/if} +
+
+ Return-Path Domain +
{headerAnalysis.domain_alignment.return_path_domain || '-'}
+ {#if headerAnalysis.domain_alignment.return_path_org_domain && headerAnalysis.domain_alignment.return_path_org_domain !== headerAnalysis.domain_alignment.return_path_domain} +
Org: {headerAnalysis.domain_alignment.return_path_org_domain}
+ {/if} +
- {/if} + {#if headerAnalysis.domain_alignment.issues && headerAnalysis.domain_alignment.issues.length > 0} +
+ {#each headerAnalysis.domain_alignment.issues as issue} +
+ + {issue} +
+ {/each} +
+ {/if} - - {#if dmarcRecord && headerAnalysis.domain_alignment.return_path_domain && headerAnalysis.domain_alignment.return_path_domain !== headerAnalysis.domain_alignment.from_domain} -
- {#if dmarcRecord.spf_alignment === 'strict'} - - Strict SPF alignment required — Your DMARC policy requires exact domain match. The Return-Path domain must exactly match the From domain for SPF to pass DMARC alignment. - {:else} - - Relaxed SPF alignment allowed — Your DMARC policy allows organizational domain matching. As long as both domains share the same organizational domain (e.g., mail.example.com and example.com), SPF alignment can pass. - {/if} + + {#if dmarcRecord && headerAnalysis.domain_alignment.return_path_domain && headerAnalysis.domain_alignment.return_path_domain !== headerAnalysis.domain_alignment.from_domain} +
+ {#if dmarcRecord.spf_alignment === 'strict'} + + Strict SPF alignment required — Your DMARC policy requires exact domain match. The Return-Path domain must exactly match the From domain for SPF to pass DMARC alignment. + {:else} + + Relaxed SPF alignment allowed — Your DMARC policy allows organizational domain matching. As long as both domains share the same organizational domain (e.g., mail.example.com and example.com), SPF alignment can pass. + {/if} +
+ {/if} +
+ + {#each headerAnalysis.domain_alignment.dkim_domains as dkim_domain} + {@const dkim_aligned = dkim_domain.domain === headerAnalysis.domain_alignment.from_domain} + {@const dkim_relaxed_aligned = dkim_domain.org_domain === headerAnalysis.domain_alignment.from_org_domain} +
+
+ DKIM +
+
+
+
+ Strict Alignment +
+ + + {dkim_aligned ? 'Pass' : 'Fail'} + +
+
Exact domain match
+
+
+ Relaxed Alignment +
+ + + {dkim_relaxed_aligned ? 'Pass' : 'Fail'} + +
+
Organizational domain match
+
+
+ From Domain +
{headerAnalysis.domain_alignment.from_domain || '-'}
+ {#if headerAnalysis.domain_alignment.from_org_domain && headerAnalysis.domain_alignment.from_org_domain !== headerAnalysis.domain_alignment.from_domain} +
Org: {headerAnalysis.domain_alignment.from_org_domain}
+ {/if} +
+
+ Signature Domain +
{dkim_domain.domain || '-'}
+ {#if dkim_domain.domain !== dkim_domain.org_domain} +
Org: {dkim_domain.org_domain}
+ {/if} +
+
+ {#if headerAnalysis.domain_alignment.issues && headerAnalysis.domain_alignment.issues.length > 0} +
+ {#each headerAnalysis.domain_alignment.issues as issue} +
+ + {issue} +
+ {/each} +
+ {/if} + + + {#if dmarcRecord && dkim_domain.domain !== headerAnalysis.domain_alignment.from_domain} + {#if dkim_domain.org_domain === headerAnalysis.domain_alignment.from_org_domain} +
+ {#if dmarcRecord.dkim_alignment === 'strict'} + + Strict DKIM alignment required — Your DMARC policy requires exact domain match. The DKIM signature domain must exactly match the From domain for DKIM to pass DMARC alignment. + {:else} + + Relaxed DKIM alignment allowed — Your DMARC policy allows organizational domain matching. As long as both domains share the same organizational domain (e.g., mail.example.com and example.com), DKIM alignment can pass. + {/if} +
+ {/if} + {/if} +
- {/if} + {/each}
{/if}