Handle relaxed domain match
This commit is contained in:
parent
84a504d668
commit
3d03bfc4fa
5 changed files with 137 additions and 35 deletions
|
|
@ -605,10 +605,18 @@ components:
|
|||
type: string
|
||||
description: Domain from From header
|
||||
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:
|
||||
type: string
|
||||
description: Domain from Return-Path header
|
||||
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:
|
||||
type: array
|
||||
items:
|
||||
|
|
@ -617,7 +625,11 @@ components:
|
|||
example: ["example.com"]
|
||||
aligned:
|
||||
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
|
||||
issues:
|
||||
type: array
|
||||
|
|
|
|||
|
|
@ -28,6 +28,8 @@ import (
|
|||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/net/publicsuffix"
|
||||
|
||||
"git.happydns.org/happyDeliver/internal/api"
|
||||
)
|
||||
|
||||
|
|
@ -52,6 +54,8 @@ func (h *HeaderAnalyzer) CalculateHeaderScore(analysis *api.HeaderAnalysis) (int
|
|||
// 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 {
|
||||
maxGrade -= 2
|
||||
}
|
||||
|
|
@ -280,7 +284,8 @@ func (h *HeaderAnalyzer) checkHeader(email *EmailMessage, headerName string, imp
|
|||
// analyzeDomainAlignment checks domain alignment between headers
|
||||
func (h *HeaderAnalyzer) analyzeDomainAlignment(email *EmailMessage) *api.DomainAlignment {
|
||||
alignment := &api.DomainAlignment{
|
||||
Aligned: api.PtrTo(true),
|
||||
Aligned: api.PtrTo(true),
|
||||
RelaxedAligned: api.PtrTo(true),
|
||||
}
|
||||
|
||||
// Extract From domain
|
||||
|
|
@ -289,6 +294,9 @@ func (h *HeaderAnalyzer) analyzeDomainAlignment(email *EmailMessage) *api.Domain
|
|||
domain := h.extractDomain(fromAddr)
|
||||
if 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)
|
||||
if domain != "" {
|
||||
alignment.ReturnPathDomain = &domain
|
||||
// Extract organizational domain
|
||||
orgDomain := h.getOrganizationalDomain(domain)
|
||||
alignment.ReturnPathOrgDomain = &orgDomain
|
||||
}
|
||||
}
|
||||
|
||||
// Check alignment
|
||||
// Check alignment (strict and relaxed)
|
||||
issues := []string{}
|
||||
if alignment.FromDomain != nil && alignment.ReturnPathDomain != nil {
|
||||
if *alignment.FromDomain != *alignment.ReturnPathDomain {
|
||||
*alignment.Aligned = false
|
||||
issues = append(issues, "Return-Path domain does not match From domain")
|
||||
fromDomain := *alignment.FromDomain
|
||||
rpDomain := *alignment.ReturnPathDomain
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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
|
||||
func (h *HeaderAnalyzer) findHeaderIssues(email *EmailMessage) []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
|
||||
formats := []string{
|
||||
time.RFC1123Z, // "Mon, 02 Jan 2006 15:04:05 -0700"
|
||||
time.RFC1123, // "Mon, 02 Jan 2006 15:04:05 MST"
|
||||
time.RFC1123Z, // "Mon, 02 Jan 2006 15:04:05 -0700"
|
||||
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 MST",
|
||||
"2 Jan 2006 15:04:05 -0700",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
<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 GradeDisplay from "./GradeDisplay.svelte";
|
||||
import MxRecordsDisplay from "./MxRecordsDisplay.svelte";
|
||||
|
|
@ -11,13 +11,14 @@
|
|||
import PtrForwardRecordsDisplay from "./PtrForwardRecordsDisplay.svelte";
|
||||
|
||||
interface Props {
|
||||
domainAlignment?: DomainAlignment;
|
||||
dnsResults?: DNSResults;
|
||||
dnsGrade?: string;
|
||||
dnsScore?: number;
|
||||
receivedChain?: ReceivedHop[];
|
||||
}
|
||||
|
||||
let { dnsResults, dnsGrade, dnsScore, receivedChain }: Props = $props();
|
||||
let { domainAlignment, dnsResults, dnsGrade, dnsScore, receivedChain }: Props = $props();
|
||||
|
||||
// Extract sender IP from first hop
|
||||
const senderIp = $derived(
|
||||
|
|
@ -81,19 +82,21 @@
|
|||
<hr class="my-4" />
|
||||
|
||||
<!-- Return-Path Domain Section -->
|
||||
<div class="mb-3 d-flex align-items-center gap-2">
|
||||
<h4 class="mb-0">
|
||||
Return-Path Domain: <code>{dnsResults.rp_domain || dnsResults.from_domain}</code>
|
||||
</h4>
|
||||
{#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 From domain</span>
|
||||
<small>
|
||||
<i class="bi bi-chevron-right"></i>
|
||||
<a href="#domain-alignment">See domain alignment</a>
|
||||
</small>
|
||||
{:else}
|
||||
<span class="badge bg-success ms-2">Same as From domain</span>
|
||||
{/if}
|
||||
<div class="mb-3">
|
||||
<div class="d-flex align-items-center gap-2 flex-wrap">
|
||||
<h4 class="mb-0">
|
||||
Return-Path Domain: <code>{dnsResults.rp_domain || dnsResults.from_domain}</code>
|
||||
</h4>
|
||||
{#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)}
|
||||
<span class="badge bg-danger ms-2"><i class="bi bi-exclamation-triangle-fill"></i> Differs from From domain</span>
|
||||
<small>
|
||||
<i class="bi bi-chevron-right"></i>
|
||||
<a href="#domain-alignment">See domain alignment</a>
|
||||
</small>
|
||||
{:else}
|
||||
<span class="badge bg-success ms-2">Same as From domain</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- MX Records for Return-Path Domain -->
|
||||
|
|
@ -117,7 +120,7 @@
|
|||
From Domain: <code>{dnsResults.from_domain}</code>
|
||||
</h4>
|
||||
{#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}
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,15 +1,16 @@
|
|||
<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 GradeDisplay from "./GradeDisplay.svelte";
|
||||
|
||||
interface Props {
|
||||
dmarcRecord: DMARCRecord;
|
||||
headerAnalysis: HeaderAnalysis;
|
||||
headerGrade?: string;
|
||||
headerScore?: number;
|
||||
}
|
||||
|
||||
let { headerAnalysis, headerGrade, headerScore }: Props = $props();
|
||||
let { dmarcRecord, headerAnalysis, headerGrade, headerScore }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="card shadow-sm">
|
||||
|
|
@ -59,7 +60,7 @@
|
|||
<div class="card mb-3" id="domain-alignment">
|
||||
<div class="card-header">
|
||||
<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
|
||||
</h5>
|
||||
</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.
|
||||
</p>
|
||||
<div class="row">
|
||||
<div class="col-md-4">
|
||||
<small class="text-muted">Aligned</small>
|
||||
<div class="col-md-3">
|
||||
<small class="text-muted">Strict Alignment</small>
|
||||
<div>
|
||||
<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>
|
||||
<strong>{headerAnalysis.domain_alignment.aligned ? 'Yes' : 'No'}</strong>
|
||||
<strong>{headerAnalysis.domain_alignment.aligned ? 'Pass' : 'Fail'}</strong>
|
||||
</span>
|
||||
</div>
|
||||
<div class="small text-muted mt-1">Exact domain match</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>
|
||||
<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 class="col-md-4">
|
||||
<div class="col-md-3">
|
||||
<small class="text-muted">Return-Path Domain</small>
|
||||
<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>
|
||||
{#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}
|
||||
<div class="text-warning small">
|
||||
<i class="bi bi-exclamation-triangle me-1"></i>
|
||||
<div class="alert alert-{headerAnalysis.domain_alignment.relaxed_aligned ? 'info' : 'warning'} py-2 small mb-2">
|
||||
<i class="bi bi-{headerAnalysis.domain_alignment.relaxed_aligned ? 'info-circle' : 'exclamation-triangle'} me-1"></i>
|
||||
{issue}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/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>
|
||||
{/if}
|
||||
|
|
|
|||
|
|
@ -143,6 +143,7 @@
|
|||
<div class="row mb-4" id="dns">
|
||||
<div class="col-12">
|
||||
<DnsRecordsCard
|
||||
domainAlignment={report.header_analysis?.domain_alignment}
|
||||
dnsResults={report.dns_results}
|
||||
dnsGrade={report.summary?.dns_grade}
|
||||
dnsScore={report.summary?.dns_score}
|
||||
|
|
@ -185,6 +186,7 @@
|
|||
<div class="row mb-4" id="header">
|
||||
<div class="col-12">
|
||||
<HeaderAnalysisCard
|
||||
dmarcRecord={report.dns_results.dmarc_record}
|
||||
headerAnalysis={report.header_analysis}
|
||||
headerGrade={report.summary?.header_grade}
|
||||
headerScore={report.summary?.header_score}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue