Handle SPF redirect

This commit is contained in:
nemunaire 2025-10-24 11:18:00 +07:00
commit 9970e957d5
3 changed files with 113 additions and 6 deletions

View file

@ -269,6 +269,18 @@ func (d *DNSAnalyzer) resolveSPFRecords(domain string, visited map[string]bool,
Error: errMsg,
})
// Check for redirect= modifier first (it replaces the entire SPF policy)
redirectDomain := d.extractSPFRedirect(spfRecord)
if redirectDomain != "" {
// redirect= replaces the current domain's policy entirely
// Only follow if no other mechanisms matched (per RFC 7208)
redirectRecords := d.resolveSPFRecords(redirectDomain, visited, depth+1)
if redirectRecords != nil {
results = append(results, *redirectRecords...)
}
return &results
}
// Extract and resolve include: directives
includes := d.extractSPFIncludes(spfRecord)
for _, includeDomain := range includes {
@ -294,6 +306,17 @@ func (d *DNSAnalyzer) extractSPFIncludes(record string) []string {
return includes
}
// extractSPFRedirect extracts the redirect= domain from an SPF record
// The redirect= modifier replaces the current domain's SPF policy with that of the target domain
func (d *DNSAnalyzer) extractSPFRedirect(record string) string {
re := regexp.MustCompile(`redirect=([^\s]+)`)
matches := re.FindStringSubmatch(record)
if len(matches) > 1 {
return matches[1]
}
return ""
}
// validateSPF performs basic SPF record validation
func (d *DNSAnalyzer) validateSPF(record string) bool {
// Must start with v=spf1
@ -301,6 +324,11 @@ func (d *DNSAnalyzer) validateSPF(record string) bool {
return false
}
// Check for redirect= modifier (which replaces the need for an 'all' mechanism)
if strings.Contains(record, "redirect=") {
return true
}
// Check for common syntax issues
// Should have a final mechanism (all, +all, -all, ~all, ?all)
validEndings := []string{" all", " +all", " -all", " ~all", " ?all"}
@ -752,8 +780,27 @@ func (d *DNSAnalyzer) CalculateDNSScore(results *api.DNSResults, senderIP string
// SPF Records: 20 points
// SPF is essential for email authentication
if results.SpfRecords != nil && len(*results.SpfRecords) > 0 {
// Check the main domain's SPF record (first in the list)
mainSPF := (*results.SpfRecords)[0]
// Find the main SPF record by skipping redirects
// Loop through records to find the last redirect or the first non-redirect
mainSPFIndex := 0
for i := 0; i < len(*results.SpfRecords); i++ {
spfRecord := (*results.SpfRecords)[i]
if spfRecord.Record != nil && strings.Contains(*spfRecord.Record, "redirect=") {
// This is a redirect, check if there's a next record
if i+1 < len(*results.SpfRecords) {
mainSPFIndex = i + 1
} else {
// Redirect exists but no target record found
break
}
} else {
// Found a non-redirect record
mainSPFIndex = i
break
}
}
mainSPF := (*results.SpfRecords)[mainSPFIndex]
if mainSPF.Valid {
// Full points for valid SPF
score += 15

View file

@ -82,13 +82,23 @@ func TestValidateSPF(t *testing.T) {
record: "v=spf1 mx ?all",
expected: true,
},
{
name: "Valid SPF with redirect",
record: "v=spf1 redirect=_spf.example.com",
expected: true,
},
{
name: "Valid SPF with redirect and mechanisms",
record: "v=spf1 ip4:192.0.2.0/24 redirect=_spf.example.com",
expected: true,
},
{
name: "Invalid SPF - no version",
record: "include:_spf.example.com -all",
expected: false,
},
{
name: "Invalid SPF - no all mechanism",
name: "Invalid SPF - no all mechanism or redirect",
record: "v=spf1 include:_spf.example.com",
expected: false,
},
@ -111,6 +121,51 @@ func TestValidateSPF(t *testing.T) {
}
}
func TestExtractSPFRedirect(t *testing.T) {
tests := []struct {
name string
record string
expectedRedirect string
}{
{
name: "SPF with redirect",
record: "v=spf1 redirect=_spf.example.com",
expectedRedirect: "_spf.example.com",
},
{
name: "SPF with redirect and other mechanisms",
record: "v=spf1 ip4:192.0.2.0/24 redirect=_spf.google.com",
expectedRedirect: "_spf.google.com",
},
{
name: "SPF without redirect",
record: "v=spf1 include:_spf.example.com -all",
expectedRedirect: "",
},
{
name: "SPF with only all mechanism",
record: "v=spf1 -all",
expectedRedirect: "",
},
{
name: "Empty record",
record: "",
expectedRedirect: "",
},
}
analyzer := NewDNSAnalyzer(5 * time.Second)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := analyzer.extractSPFRedirect(tt.record)
if result != tt.expectedRedirect {
t.Errorf("extractSPFRedirect(%q) = %q, want %q", tt.record, result, tt.expectedRedirect)
}
})
}
}
func TestValidateDKIM(t *testing.T) {
tests := []struct {
name string

View file

@ -11,6 +11,9 @@
const spfIsValid = $derived(
spfRecords?.reduce((acc, r) => acc && r.valid, true) ?? false
);
const spfCanBeImprove = $derived(
spfRecords.length > 0 && spfRecords.filter((r) => !r.record.includes(" redirect="))[0]?.all_qualifier != "-"
);
</script>
{#if spfRecords && spfRecords.length > 0}
@ -19,8 +22,10 @@
<h5 class="text-muted mb-2">
<i
class="bi"
class:bi-check-circle-fill={spfIsValid}
class:text-success={spfIsValid}
class:bi-check-circle-fill={spfIsValid && !spfCanBeImprove}
class:text-success={spfIsValid && !spfCanBeImprove}
class:bi-arrow-up-circle-fill={spfIsValid && spfCanBeImprove}
class:text-warning={spfIsValid && spfCanBeImprove}
class:bi-x-circle-fill={!spfIsValid}
class:text-danger={!spfIsValid}
></i>
@ -62,7 +67,7 @@
{:else if spf.all_qualifier === '?'}
<span class="badge bg-warning">Neutral (?all)</span>
{/if}
{#if index === 0}
{#if index === 0 || (index === 1 && spfRecords[0].record?.includes('redirect='))}
<div class="alert small mt-2" class:alert-warning={spf.all_qualifier !== '-'} class:alert-success={spf.all_qualifier === '-'}>
{#if spf.all_qualifier === '-'}
All unauthorized servers will be rejected. This is the recommended strict policy.