Check SPF include

This commit is contained in:
nemunaire 2025-10-22 17:42:41 +07:00
commit f6a1ea73a2
3 changed files with 160 additions and 46 deletions

View file

@ -788,8 +788,11 @@ components:
items: items:
$ref: '#/components/schemas/MXRecord' $ref: '#/components/schemas/MXRecord'
description: MX records for the domain description: MX records for the domain
spf_record: spf_records:
$ref: '#/components/schemas/SPFRecord' type: array
items:
$ref: '#/components/schemas/SPFRecord'
description: SPF records found (includes resolved include directives)
dkim_records: dkim_records:
type: array type: array
items: items:
@ -835,6 +838,10 @@ components:
required: required:
- valid - valid
properties: properties:
domain:
type: string
description: Domain this SPF record belongs to
example: "example.com"
record: record:
type: string type: string
description: SPF record content description: SPF record content

View file

@ -68,8 +68,8 @@ func (d *DNSAnalyzer) AnalyzeDNS(email *EmailMessage, authResults *api.Authentic
// Check MX records // Check MX records
results.MxRecords = d.checkMXRecords(domain) results.MxRecords = d.checkMXRecords(domain)
// Check SPF record // Check SPF records (including includes)
results.SpfRecord = d.checkSPFRecord(domain) results.SpfRecords = d.checkSPFRecords(domain)
// Check DKIM records (from authentication results) // Check DKIM records (from authentication results)
if authResults != nil && authResults.Dkim != nil { if authResults != nil && authResults.Dkim != nil {
@ -142,16 +142,43 @@ func (d *DNSAnalyzer) checkMXRecords(domain string) *[]api.MXRecord {
return &results return &results
} }
// checkSPFRecord looks up and validates SPF record for a domain // checkSPFRecords looks up and validates SPF records for a domain, including resolving include: directives
func (d *DNSAnalyzer) checkSPFRecord(domain string) *api.SPFRecord { func (d *DNSAnalyzer) checkSPFRecords(domain string) *[]api.SPFRecord {
visited := make(map[string]bool)
return d.resolveSPFRecords(domain, visited, 0)
}
// resolveSPFRecords recursively resolves SPF records including include: directives
func (d *DNSAnalyzer) resolveSPFRecords(domain string, visited map[string]bool, depth int) *[]api.SPFRecord {
const maxDepth = 10 // Prevent infinite recursion
if depth > maxDepth {
return &[]api.SPFRecord{
{
Domain: &domain,
Valid: false,
Error: api.PtrTo("Maximum SPF include depth exceeded"),
},
}
}
// Prevent circular references
if visited[domain] {
return &[]api.SPFRecord{}
}
visited[domain] = true
ctx, cancel := context.WithTimeout(context.Background(), d.Timeout) ctx, cancel := context.WithTimeout(context.Background(), d.Timeout)
defer cancel() defer cancel()
txtRecords, err := d.resolver.LookupTXT(ctx, domain) txtRecords, err := d.resolver.LookupTXT(ctx, domain)
if err != nil { if err != nil {
return &api.SPFRecord{ return &[]api.SPFRecord{
Valid: false, {
Error: api.PtrTo(fmt.Sprintf("Failed to lookup TXT records: %v", err)), Domain: &domain,
Valid: false,
Error: api.PtrTo(fmt.Sprintf("Failed to lookup TXT records: %v", err)),
},
} }
} }
@ -166,33 +193,77 @@ func (d *DNSAnalyzer) checkSPFRecord(domain string) *api.SPFRecord {
} }
if spfCount == 0 { if spfCount == 0 {
return &api.SPFRecord{ return &[]api.SPFRecord{
Valid: false, {
Error: api.PtrTo("No SPF record found"), Domain: &domain,
Valid: false,
Error: api.PtrTo("No SPF record found"),
},
} }
} }
var results []api.SPFRecord
if spfCount > 1 { if spfCount > 1 {
return &api.SPFRecord{ results = append(results, api.SPFRecord{
Domain: &domain,
Record: &spfRecord, Record: &spfRecord,
Valid: false, Valid: false,
Error: api.PtrTo("Multiple SPF records found (RFC violation)"), Error: api.PtrTo("Multiple SPF records found (RFC violation)"),
} })
return &results
} }
// Basic validation // Basic validation
if !d.validateSPF(spfRecord) { valid := d.validateSPF(spfRecord)
return &api.SPFRecord{
Record: &spfRecord, // Check for strict -all mechanism
Valid: false, var errMsg *string
Error: api.PtrTo("SPF record appears malformed"), if !valid {
errMsg = api.PtrTo("SPF record appears malformed")
} else if !d.hasSPFStrictFail(spfRecord) {
// Check what mechanism is used
if strings.HasSuffix(spfRecord, " ~all") {
errMsg = api.PtrTo("SPF uses ~all (softfail) instead of -all (hardfail). This weakens email authentication and may reduce deliverability.")
} else if strings.HasSuffix(spfRecord, " +all") || strings.HasSuffix(spfRecord, " ?all") {
errMsg = api.PtrTo("SPF uses permissive 'all' mechanism. This severely weakens email authentication. Use -all for strict policy.")
} else if strings.HasSuffix(spfRecord, " all") {
errMsg = api.PtrTo("SPF uses neutral 'all' mechanism. Use -all for strict policy to improve deliverability.")
} else {
errMsg = api.PtrTo("SPF record should end with -all for strict policy to improve deliverability and prevent spoofing.")
} }
} }
return &api.SPFRecord{ results = append(results, api.SPFRecord{
Domain: &domain,
Record: &spfRecord, Record: &spfRecord,
Valid: true, Valid: valid,
Error: errMsg,
})
// Extract and resolve include: directives
includes := d.extractSPFIncludes(spfRecord)
for _, includeDomain := range includes {
includedRecords := d.resolveSPFRecords(includeDomain, visited, depth+1)
if includedRecords != nil {
results = append(results, *includedRecords...)
}
} }
return &results
}
// extractSPFIncludes extracts all include: domains from an SPF record
func (d *DNSAnalyzer) extractSPFIncludes(record string) []string {
var includes []string
re := regexp.MustCompile(`include:([^\s]+)`)
matches := re.FindAllStringSubmatch(record, -1)
for _, match := range matches {
if len(match) > 1 {
includes = append(includes, match[1])
}
}
return includes
} }
// validateSPF performs basic SPF record validation // validateSPF performs basic SPF record validation
@ -216,6 +287,11 @@ func (d *DNSAnalyzer) validateSPF(record string) bool {
return hasValidEnding return hasValidEnding
} }
// hasSPFStrictFail checks if SPF record has strict -all mechanism
func (d *DNSAnalyzer) hasSPFStrictFail(record string) bool {
return strings.HasSuffix(record, " -all")
}
// checkapi.DKIMRecord looks up and validates DKIM record for a domain and selector // checkapi.DKIMRecord looks up and validates DKIM record for a domain and selector
func (d *DNSAnalyzer) checkDKIMRecord(domain, selector string) *api.DKIMRecord { func (d *DNSAnalyzer) checkDKIMRecord(domain, selector string) *api.DKIMRecord {
// DKIM records are at: selector._domainkey.domain // DKIM records are at: selector._domainkey.domain
@ -468,12 +544,32 @@ func (d *DNSAnalyzer) CalculateDNSScore(results *api.DNSResults) (int, string) {
} }
} }
// SPF Record: 20 points // SPF Records: 20 points
// SPF is essential for email authentication // SPF is essential for email authentication
if results.SpfRecord != nil { if results.SpfRecords != nil && len(*results.SpfRecords) > 0 {
if results.SpfRecord.Valid { // Check the main domain's SPF record (first in the list)
mainSPF := (*results.SpfRecords)[0]
if mainSPF.Valid {
// Full points for valid SPF
score += 20 score += 20
} else if results.SpfRecord.Record != nil {
// Check for strict -all mechanism
if mainSPF.Record != nil && !d.hasSPFStrictFail(*mainSPF.Record) {
// Deduct points for weak SPF policy
if strings.HasSuffix(*mainSPF.Record, " ~all") {
// Softfail - moderate penalty
score -= 5
} else if strings.HasSuffix(*mainSPF.Record, " +all") ||
strings.HasSuffix(*mainSPF.Record, " ?all") ||
strings.HasSuffix(*mainSPF.Record, " all") {
// Pass/neutral - severe penalty
score -= 10
} else {
// No 'all' mechanism at all - severe penalty
score -= 10
}
}
} else if mainSPF.Record != nil {
// Partial credit if SPF record exists but has issues // Partial credit if SPF record exists but has issues
score += 5 score += 5
} }

View file

@ -88,35 +88,46 @@
</div> </div>
{/if} {/if}
<!-- SPF Record --> <!-- SPF Records -->
{#if dnsResults.spf_record} {#if dnsResults.spf_records && dnsResults.spf_records.length > 0}
<div class="mb-4"> <div class="mb-4">
<h5 class="text-muted mb-2"> <h5 class="text-muted mb-2">
<span class="badge bg-secondary">SPF</span> Sender Policy Framework <span class="badge bg-secondary">SPF</span> Sender Policy Framework
</h5> </h5>
<div class="card"> {#each dnsResults.spf_records as spf, index}
<div class="card-body"> <div class="card mb-2">
<div class="mb-2"> <div class="card-body">
<strong>Status:</strong> {#if spf.domain}
{#if dnsResults.spf_record.valid} <div class="mb-2">
<span class="badge bg-success">Valid</span> <strong>Domain:</strong> <code>{spf.domain}</code>
{:else} {#if index > 0}
<span class="badge bg-danger">Invalid</span> <span class="badge bg-info ms-2">Included</span>
{/if}
</div>
{/if}
<div class="mb-2">
<strong>Status:</strong>
{#if spf.valid}
<span class="badge bg-success">Valid</span>
{:else}
<span class="badge bg-danger">Invalid</span>
{/if}
</div>
{#if spf.record}
<div class="mb-2">
<strong>Record:</strong><br>
<code class="d-block mt-1 text-break">{spf.record}</code>
</div>
{/if}
{#if spf.error}
<div class="alert alert-{spf.valid ? 'warning' : 'danger'} mb-0 mt-2">
<i class="bi bi-{spf.valid ? 'exclamation-triangle' : 'x-circle'} me-1"></i>
<strong>{spf.valid ? 'Warning:' : 'Error:'}</strong> {spf.error}
</div>
{/if} {/if}
</div> </div>
{#if dnsResults.spf_record.record}
<div class="mb-2">
<strong>Record:</strong><br>
<code class="d-block mt-1 text-break">{dnsResults.spf_record.record}</code>
</div>
{/if}
{#if dnsResults.spf_record.error}
<div class="text-danger">
<strong>Error:</strong> {dnsResults.spf_record.error}
</div>
{/if}
</div> </div>
</div> {/each}
</div> </div>
{/if} {/if}