Handle relaxed domain match

This commit is contained in:
nemunaire 2025-10-23 16:44:50 +07:00
commit 3d03bfc4fa
5 changed files with 137 additions and 35 deletions

View file

@ -605,10 +605,18 @@ components:
type: string type: string
description: Domain from From header description: Domain from From header
example: "example.com" example: "example.com"
from_org_domain:
type: string
description: Organizational domain extracted from From header (using Public Suffix List)
example: "example.com"
return_path_domain: return_path_domain:
type: string type: string
description: Domain from Return-Path header description: Domain from Return-Path header
example: "example.com" example: "example.com"
return_path_org_domain:
type: string
description: Organizational domain extracted from Return-Path header (using Public Suffix List)
example: "example.com"
dkim_domains: dkim_domains:
type: array type: array
items: items:
@ -617,7 +625,11 @@ components:
example: ["example.com"] example: ["example.com"]
aligned: aligned:
type: boolean type: boolean
description: Whether all domains align description: Whether all domains align (strict alignment - exact match)
example: true
relaxed_aligned:
type: boolean
description: Whether domains satisfy relaxed alignment (organizational domain match)
example: true example: true
issues: issues:
type: array type: array

View file

@ -28,6 +28,8 @@ import (
"strings" "strings"
"time" "time"
"golang.org/x/net/publicsuffix"
"git.happydns.org/happyDeliver/internal/api" "git.happydns.org/happyDeliver/internal/api"
) )
@ -52,6 +54,8 @@ func (h *HeaderAnalyzer) CalculateHeaderScore(analysis *api.HeaderAnalysis) (int
// RP and From alignment (20 points) // RP and From alignment (20 points)
if analysis.DomainAlignment.Aligned != nil && *analysis.DomainAlignment.Aligned { if analysis.DomainAlignment.Aligned != nil && *analysis.DomainAlignment.Aligned {
score += 20 score += 20
} else if analysis.DomainAlignment.RelaxedAligned != nil && *analysis.DomainAlignment.RelaxedAligned {
score += 15
} else { } else {
maxGrade -= 2 maxGrade -= 2
} }
@ -280,7 +284,8 @@ func (h *HeaderAnalyzer) checkHeader(email *EmailMessage, headerName string, imp
// analyzeDomainAlignment checks domain alignment between headers // analyzeDomainAlignment checks domain alignment between headers
func (h *HeaderAnalyzer) analyzeDomainAlignment(email *EmailMessage) *api.DomainAlignment { func (h *HeaderAnalyzer) analyzeDomainAlignment(email *EmailMessage) *api.DomainAlignment {
alignment := &api.DomainAlignment{ alignment := &api.DomainAlignment{
Aligned: api.PtrTo(true), Aligned: api.PtrTo(true),
RelaxedAligned: api.PtrTo(true),
} }
// Extract From domain // Extract From domain
@ -289,6 +294,9 @@ func (h *HeaderAnalyzer) analyzeDomainAlignment(email *EmailMessage) *api.Domain
domain := h.extractDomain(fromAddr) domain := h.extractDomain(fromAddr)
if domain != "" { if domain != "" {
alignment.FromDomain = &domain alignment.FromDomain = &domain
// Extract organizational domain
orgDomain := h.getOrganizationalDomain(domain)
alignment.FromOrgDomain = &orgDomain
} }
} }
@ -298,15 +306,40 @@ func (h *HeaderAnalyzer) analyzeDomainAlignment(email *EmailMessage) *api.Domain
domain := h.extractDomain(returnPath) domain := h.extractDomain(returnPath)
if domain != "" { if domain != "" {
alignment.ReturnPathDomain = &domain alignment.ReturnPathDomain = &domain
// Extract organizational domain
orgDomain := h.getOrganizationalDomain(domain)
alignment.ReturnPathOrgDomain = &orgDomain
} }
} }
// Check alignment // Check alignment (strict and relaxed)
issues := []string{} issues := []string{}
if alignment.FromDomain != nil && alignment.ReturnPathDomain != nil { if alignment.FromDomain != nil && alignment.ReturnPathDomain != nil {
if *alignment.FromDomain != *alignment.ReturnPathDomain { fromDomain := *alignment.FromDomain
*alignment.Aligned = false rpDomain := *alignment.ReturnPathDomain
issues = append(issues, "Return-Path domain does not match From domain")
// Strict alignment: exact match (case-insensitive)
strictAligned := strings.EqualFold(fromDomain, rpDomain)
// Relaxed alignment: organizational domain match
var fromOrgDomain, rpOrgDomain string
if alignment.FromOrgDomain != nil {
fromOrgDomain = *alignment.FromOrgDomain
}
if alignment.ReturnPathOrgDomain != nil {
rpOrgDomain = *alignment.ReturnPathOrgDomain
}
relaxedAligned := strings.EqualFold(fromOrgDomain, rpOrgDomain)
*alignment.Aligned = strictAligned
*alignment.RelaxedAligned = relaxedAligned
if !strictAligned {
if relaxedAligned {
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))
}
} }
} }
@ -335,6 +368,27 @@ func (h *HeaderAnalyzer) extractDomain(emailAddr string) string {
return domain return domain
} }
// 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 {
domain = strings.ToLower(strings.TrimSpace(domain))
// Use golang.org/x/net/publicsuffix to get the eTLD+1 (organizational domain)
// This correctly handles cases like .co.uk, .com.au, etc.
etldPlusOne, err := publicsuffix.EffectiveTLDPlusOne(domain)
if err != nil {
// Fallback to simple two-label extraction if PSL lookup fails
labels := strings.Split(domain, ".")
if len(labels) <= 2 {
return domain
}
return strings.Join(labels[len(labels)-2:], ".")
}
return etldPlusOne
}
// findHeaderIssues identifies issues with headers // findHeaderIssues identifies issues with headers
func (h *HeaderAnalyzer) findHeaderIssues(email *EmailMessage) []api.HeaderIssue { func (h *HeaderAnalyzer) findHeaderIssues(email *EmailMessage) []api.HeaderIssue {
var issues []api.HeaderIssue var issues []api.HeaderIssue
@ -458,8 +512,8 @@ func (h *HeaderAnalyzer) parseReceivedHeader(receivedValue string) *api.Received
// Try parsing with common email date formats // Try parsing with common email date formats
formats := []string{ formats := []string{
time.RFC1123Z, // "Mon, 02 Jan 2006 15:04:05 -0700" time.RFC1123Z, // "Mon, 02 Jan 2006 15:04:05 -0700"
time.RFC1123, // "Mon, 02 Jan 2006 15:04:05 MST" time.RFC1123, // "Mon, 02 Jan 2006 15:04:05 MST"
"Mon, 2 Jan 2006 15:04:05 -0700", "Mon, 2 Jan 2006 15:04:05 -0700",
"Mon, 2 Jan 2006 15:04:05 MST", "Mon, 2 Jan 2006 15:04:05 MST",
"2 Jan 2006 15:04:05 -0700", "2 Jan 2006 15:04:05 -0700",

View file

@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import type { DNSResults, ReceivedHop } from "$lib/api/types.gen"; import type { DomainAlignment, DNSResults, ReceivedHop } from "$lib/api/types.gen";
import { getScoreColorClass } from "$lib/score"; import { getScoreColorClass } from "$lib/score";
import GradeDisplay from "./GradeDisplay.svelte"; import GradeDisplay from "./GradeDisplay.svelte";
import MxRecordsDisplay from "./MxRecordsDisplay.svelte"; import MxRecordsDisplay from "./MxRecordsDisplay.svelte";
@ -11,13 +11,14 @@
import PtrForwardRecordsDisplay from "./PtrForwardRecordsDisplay.svelte"; import PtrForwardRecordsDisplay from "./PtrForwardRecordsDisplay.svelte";
interface Props { interface Props {
domainAlignment?: DomainAlignment;
dnsResults?: DNSResults; dnsResults?: DNSResults;
dnsGrade?: string; dnsGrade?: string;
dnsScore?: number; dnsScore?: number;
receivedChain?: ReceivedHop[]; receivedChain?: ReceivedHop[];
} }
let { dnsResults, dnsGrade, dnsScore, receivedChain }: Props = $props(); let { domainAlignment, dnsResults, dnsGrade, dnsScore, receivedChain }: Props = $props();
// Extract sender IP from first hop // Extract sender IP from first hop
const senderIp = $derived( const senderIp = $derived(
@ -81,19 +82,21 @@
<hr class="my-4" /> <hr class="my-4" />
<!-- Return-Path Domain Section --> <!-- Return-Path Domain Section -->
<div class="mb-3 d-flex align-items-center gap-2"> <div class="mb-3">
<h4 class="mb-0"> <div class="d-flex align-items-center gap-2 flex-wrap">
Return-Path Domain: <code>{dnsResults.rp_domain || dnsResults.from_domain}</code> <h4 class="mb-0">
</h4> Return-Path Domain: <code>{dnsResults.rp_domain || dnsResults.from_domain}</code>
{#if dnsResults.rp_domain && dnsResults.rp_domain !== dnsResults.from_domain} </h4>
<span class="badge bg-danger ms-2"><i class="bi bi-exclamation-triangle-fill"></i> Different from From domain</span> {#if (domainAlignment && !domainAlignment.aligned && !domainAlignment.relaxed_aligned) || (domainAlignment && !domainAlignment.aligned && domainAlignment.relaxed_aligned && dnsResults.dmarc_record && dnsResults.dmarc_record.spf_alignment === "strict") || (!domainAlignment && dnsResults.rp_domain && dnsResults.rp_domain !== dnsResults.from_domain)}
<small> <span class="badge bg-danger ms-2"><i class="bi bi-exclamation-triangle-fill"></i> Differs from From domain</span>
<i class="bi bi-chevron-right"></i> <small>
<a href="#domain-alignment">See domain alignment</a> <i class="bi bi-chevron-right"></i>
</small> <a href="#domain-alignment">See domain alignment</a>
{:else} </small>
<span class="badge bg-success ms-2">Same as From domain</span> {:else}
{/if} <span class="badge bg-success ms-2">Same as From domain</span>
{/if}
</div>
</div> </div>
<!-- MX Records for Return-Path Domain --> <!-- MX Records for Return-Path Domain -->
@ -117,7 +120,7 @@
From Domain: <code>{dnsResults.from_domain}</code> From Domain: <code>{dnsResults.from_domain}</code>
</h4> </h4>
{#if dnsResults.rp_domain && dnsResults.rp_domain !== dnsResults.from_domain} {#if dnsResults.rp_domain && dnsResults.rp_domain !== dnsResults.from_domain}
<span class="badge bg-danger ms-2"><i class="bi bi-exclamation-triangle-fill"></i> Different from Return-Path domain</span> <span class="badge bg-danger ms-2"><i class="bi bi-exclamation-triangle-fill"></i> Differs from Return-Path domain</span>
{/if} {/if}
</div> </div>

View file

@ -1,15 +1,16 @@
<script lang="ts"> <script lang="ts">
import type { HeaderAnalysis } from "$lib/api/types.gen"; import type { DMARCRecord, HeaderAnalysis } from "$lib/api/types.gen";
import { getScoreColorClass } from "$lib/score"; import { getScoreColorClass } from "$lib/score";
import GradeDisplay from "./GradeDisplay.svelte"; import GradeDisplay from "./GradeDisplay.svelte";
interface Props { interface Props {
dmarcRecord: DMARCRecord;
headerAnalysis: HeaderAnalysis; headerAnalysis: HeaderAnalysis;
headerGrade?: string; headerGrade?: string;
headerScore?: number; headerScore?: number;
} }
let { headerAnalysis, headerGrade, headerScore }: Props = $props(); let { dmarcRecord, headerAnalysis, headerGrade, headerScore }: Props = $props();
</script> </script>
<div class="card shadow-sm"> <div class="card shadow-sm">
@ -59,7 +60,7 @@
<div class="card mb-3" id="domain-alignment"> <div class="card mb-3" id="domain-alignment">
<div class="card-header"> <div class="card-header">
<h5 class="mb-0"> <h5 class="mb-0">
<i class="bi {headerAnalysis.domain_alignment.aligned ? 'bi-check-circle-fill text-success' : 'bi-x-circle-fill text-danger'}"></i> <i class="bi {headerAnalysis.domain_alignment.aligned ? 'bi-check-circle-fill text-success' : headerAnalysis.domain_alignment.relaxed_aligned ? 'bi-check-circle text-info' : 'bi-x-circle-fill text-danger'}"></i>
Domain Alignment Domain Alignment
</h5> </h5>
</div> </div>
@ -68,34 +69,64 @@
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). Proper alignment is crucial for DMARC compliance and helps prevent email spoofing by verifying that the sender domain is consistent across all authentication layers.
</p> </p>
<div class="row"> <div class="row">
<div class="col-md-4"> <div class="col-md-3">
<small class="text-muted">Aligned</small> <small class="text-muted">Strict Alignment</small>
<div> <div>
<span class="badge" class:bg-success={headerAnalysis.domain_alignment.aligned} class:bg-danger={!headerAnalysis.domain_alignment.aligned}> <span class="badge" class:bg-success={headerAnalysis.domain_alignment.aligned} class:bg-danger={!headerAnalysis.domain_alignment.aligned}>
<i class="bi {headerAnalysis.domain_alignment.aligned ? 'bi-check-circle-fill' : 'bi-x-circle-fill'} me-1"></i> <i class="bi {headerAnalysis.domain_alignment.aligned ? 'bi-check-circle-fill' : 'bi-x-circle-fill'} me-1"></i>
<strong>{headerAnalysis.domain_alignment.aligned ? 'Yes' : 'No'}</strong> <strong>{headerAnalysis.domain_alignment.aligned ? 'Pass' : 'Fail'}</strong>
</span> </span>
</div> </div>
<div class="small text-muted mt-1">Exact domain match</div>
</div> </div>
<div class="col-md-4"> <div class="col-md-3">
<small class="text-muted">Relaxed Alignment</small>
<div>
<span class="badge" class:bg-success={headerAnalysis.domain_alignment.relaxed_aligned} class:bg-danger={!headerAnalysis.domain_alignment.relaxed_aligned}>
<i class="bi {headerAnalysis.domain_alignment.relaxed_aligned ? 'bi-check-circle-fill' : 'bi-x-circle-fill'} me-1"></i>
<strong>{headerAnalysis.domain_alignment.relaxed_aligned ? 'Pass' : 'Fail'}</strong>
</span>
</div>
<div class="small text-muted mt-1">Organizational domain match</div>
</div>
<div class="col-md-3">
<small class="text-muted">From Domain</small> <small class="text-muted">From Domain</small>
<div><code>{headerAnalysis.domain_alignment.from_domain || '-'}</code></div> <div><code>{headerAnalysis.domain_alignment.from_domain || '-'}</code></div>
{#if headerAnalysis.domain_alignment.from_org_domain && headerAnalysis.domain_alignment.from_org_domain !== headerAnalysis.domain_alignment.from_domain}
<div class="small text-muted mt-1">Org: <code>{headerAnalysis.domain_alignment.from_org_domain}</code></div>
{/if}
</div> </div>
<div class="col-md-4"> <div class="col-md-3">
<small class="text-muted">Return-Path Domain</small> <small class="text-muted">Return-Path Domain</small>
<div><code>{headerAnalysis.domain_alignment.return_path_domain || '-'}</code></div> <div><code>{headerAnalysis.domain_alignment.return_path_domain || '-'}</code></div>
{#if headerAnalysis.domain_alignment.return_path_org_domain && headerAnalysis.domain_alignment.return_path_org_domain !== headerAnalysis.domain_alignment.return_path_domain}
<div class="small text-muted mt-1">Org: <code>{headerAnalysis.domain_alignment.return_path_org_domain}</code></div>
{/if}
</div> </div>
</div> </div>
{#if headerAnalysis.domain_alignment.issues && headerAnalysis.domain_alignment.issues.length > 0} {#if headerAnalysis.domain_alignment.issues && headerAnalysis.domain_alignment.issues.length > 0}
<div class="mt-2"> <div class="mt-3">
{#each headerAnalysis.domain_alignment.issues as issue} {#each headerAnalysis.domain_alignment.issues as issue}
<div class="text-warning small"> <div class="alert alert-{headerAnalysis.domain_alignment.relaxed_aligned ? 'info' : 'warning'} py-2 small mb-2">
<i class="bi bi-exclamation-triangle me-1"></i> <i class="bi bi-{headerAnalysis.domain_alignment.relaxed_aligned ? 'info-circle' : 'exclamation-triangle'} me-1"></i>
{issue} {issue}
</div> </div>
{/each} {/each}
</div> </div>
{/if} {/if}
<!-- Alignment Information based on DMARC policy -->
{#if dmarcRecord && headerAnalysis.domain_alignment.return_path_domain && headerAnalysis.domain_alignment.return_path_domain !== headerAnalysis.domain_alignment.from_domain}
<div class="alert mt-2 mb-0 small py-2 {dmarcRecord.spf_alignment === 'strict' ? 'alert-warning' : 'alert-info'}">
{#if dmarcRecord.spf_alignment === 'strict'}
<i class="bi bi-exclamation-triangle me-1"></i>
<strong>Strict SPF alignment required</strong> — Your DMARC policy requires exact domain match. The Return-Path domain must exactly match the From domain for SPF to pass DMARC alignment.
{:else}
<i class="bi bi-info-circle me-1"></i>
<strong>Relaxed SPF alignment allowed</strong> — 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}
</div>
{/if}
</div> </div>
</div> </div>
{/if} {/if}

View file

@ -143,6 +143,7 @@
<div class="row mb-4" id="dns"> <div class="row mb-4" id="dns">
<div class="col-12"> <div class="col-12">
<DnsRecordsCard <DnsRecordsCard
domainAlignment={report.header_analysis?.domain_alignment}
dnsResults={report.dns_results} dnsResults={report.dns_results}
dnsGrade={report.summary?.dns_grade} dnsGrade={report.summary?.dns_grade}
dnsScore={report.summary?.dns_score} dnsScore={report.summary?.dns_score}
@ -185,6 +186,7 @@
<div class="row mb-4" id="header"> <div class="row mb-4" id="header">
<div class="col-12"> <div class="col-12">
<HeaderAnalysisCard <HeaderAnalysisCard
dmarcRecord={report.dns_results.dmarc_record}
headerAnalysis={report.header_analysis} headerAnalysis={report.header_analysis}
headerGrade={report.summary?.header_grade} headerGrade={report.summary?.header_grade}
headerScore={report.summary?.header_score} headerScore={report.summary?.header_score}