Add reverse lookup and forward confirmation

This commit is contained in:
nemunaire 2025-10-23 15:59:57 +07:00
commit 84a504d668
8 changed files with 324 additions and 5 deletions

View file

@ -819,6 +819,18 @@ components:
$ref: '#/components/schemas/DMARCRecord'
bimi_record:
$ref: '#/components/schemas/BIMIRecord'
ptr_records:
type: array
items:
type: string
description: PTR (reverse DNS) records for the sender IP address
example: ["mail.example.com", "smtp.example.com"]
ptr_forward_records:
type: array
items:
type: string
description: A or AAAA records resolved from the PTR hostnames (forward confirmation)
example: ["192.0.2.1", "2001:db8::1"]
errors:
type: array
items:

View file

@ -73,6 +73,22 @@ func (d *DNSAnalyzer) AnalyzeDNS(email *EmailMessage, authResults *api.Authentic
spfDomain = *results.RpDomain
}
// Store sender IP for later use in scoring
var senderIP string
if headersResults.ReceivedChain != nil && len(*headersResults.ReceivedChain) > 0 {
firstHop := (*headersResults.ReceivedChain)[0]
if firstHop.Ip != nil && *firstHop.Ip != "" {
senderIP = *firstHop.Ip
ptrRecords, forwardRecords := d.checkPTRAndForward(senderIP)
if len(ptrRecords) > 0 {
results.PtrRecords = &ptrRecords
}
if len(forwardRecords) > 0 {
results.PtrForwardRecords = &forwardRecords
}
}
}
// Check MX records for From domain (where replies would go)
results.FromMxRecords = d.checkMXRecords(fromDomain)
@ -613,16 +629,78 @@ func (d *DNSAnalyzer) validateBIMI(record string) bool {
return true
}
// checkPTRAndForward performs reverse DNS lookup (PTR) and forward confirmation (A/AAAA)
// Returns PTR hostnames and their corresponding forward-resolved IPs
func (d *DNSAnalyzer) checkPTRAndForward(ip string) ([]string, []string) {
ctx, cancel := context.WithTimeout(context.Background(), d.Timeout)
defer cancel()
// Perform reverse DNS lookup (PTR)
ptrNames, err := d.resolver.LookupAddr(ctx, ip)
if err != nil || len(ptrNames) == 0 {
return nil, nil
}
var forwardIPs []string
seenIPs := make(map[string]bool)
// For each PTR record, perform forward DNS lookup (A/AAAA)
for _, ptrName := range ptrNames {
// Look up A records
ctx, cancel := context.WithTimeout(context.Background(), d.Timeout)
aRecords, err := d.resolver.LookupHost(ctx, ptrName)
cancel()
if err == nil {
for _, forwardIP := range aRecords {
if !seenIPs[forwardIP] {
forwardIPs = append(forwardIPs, forwardIP)
seenIPs[forwardIP] = true
}
}
}
}
return ptrNames, forwardIPs
}
// CalculateDNSScore calculates the DNS score from records results
// Returns a score from 0-100 where higher is better
func (d *DNSAnalyzer) CalculateDNSScore(results *api.DNSResults) (int, string) {
// senderIP is the original sender IP address used for FCrDNS verification
func (d *DNSAnalyzer) CalculateDNSScore(results *api.DNSResults, senderIP string) (int, string) {
if results == nil {
return 0, ""
}
score := 0
// TODO: 20 points for correct PTR and A/AAAA
// PTR and Forward DNS: 20 points
// Proper reverse DNS (PTR) and forward-confirmed reverse DNS (FCrDNS) is important for deliverability
if results.PtrRecords != nil && len(*results.PtrRecords) > 0 {
// 10 points for having PTR records
score += 10
if len(*results.PtrRecords) > 1 {
// Penalty has it's bad to have multiple PTR records
score -= 3
}
// Additional 10 points for forward-confirmed reverse DNS (FCrDNS)
// This means the PTR hostname resolves back to IPs that include the original sender IP
if results.PtrForwardRecords != nil && len(*results.PtrForwardRecords) > 0 && senderIP != "" {
// Verify that the sender IP is in the list of forward-resolved IPs
fcrDnsValid := false
for _, forwardIP := range *results.PtrForwardRecords {
if forwardIP == senderIP {
fcrDnsValid = true
break
}
}
if fcrDnsValid {
score += 10
}
}
}
// MX Records: 20 points (10 for From domain, 10 for Return-Path domain)
// Having valid MX records is critical for email deliverability

View file

@ -98,7 +98,15 @@ func (r *ReportGenerator) GenerateReport(testID uuid.UUID, results *AnalysisResu
dnsScore := 0
var dnsGrade string
if results.DNS != nil {
dnsScore, dnsGrade = r.dnsAnalyzer.CalculateDNSScore(results.DNS)
// Extract sender IP from received chain for FCrDNS verification
var senderIP string
if results.Headers != nil && results.Headers.ReceivedChain != nil && len(*results.Headers.ReceivedChain) > 0 {
firstHop := (*results.Headers.ReceivedChain)[0]
if firstHop.Ip != nil {
senderIP = *firstHop.Ip
}
}
dnsScore, dnsGrade = r.dnsAnalyzer.CalculateDNSScore(results.DNS, senderIP)
}
authScore := 0
@ -178,6 +186,7 @@ func (r *ReportGenerator) GenerateReport(testID uuid.UUID, results *AnalysisResu
// Calculate overall score as mean of all category scores
categoryScores := []int{
report.Summary.DnsScore,
report.Summary.AuthenticationScore,
report.Summary.BlacklistScore,
report.Summary.ContentScore,

View file

@ -1,5 +1,5 @@
<script lang="ts">
import type { DNSResults } from "$lib/api/types.gen";
import type { DNSResults, ReceivedHop } from "$lib/api/types.gen";
import { getScoreColorClass } from "$lib/score";
import GradeDisplay from "./GradeDisplay.svelte";
import MxRecordsDisplay from "./MxRecordsDisplay.svelte";
@ -7,14 +7,22 @@
import DkimRecordsDisplay from "./DkimRecordsDisplay.svelte";
import DmarcRecordDisplay from "./DmarcRecordDisplay.svelte";
import BimiRecordDisplay from "./BimiRecordDisplay.svelte";
import PtrRecordsDisplay from "./PtrRecordsDisplay.svelte";
import PtrForwardRecordsDisplay from "./PtrForwardRecordsDisplay.svelte";
interface Props {
dnsResults?: DNSResults;
dnsGrade?: string;
dnsScore?: number;
receivedChain?: ReceivedHop[];
}
let { dnsResults, dnsGrade, dnsScore }: Props = $props();
let { dnsResults, dnsGrade, dnsScore, receivedChain }: Props = $props();
// Extract sender IP from first hop
const senderIp = $derived(
receivedChain && receivedChain.length > 0 ? receivedChain[0].ip : undefined,
);
</script>
<div class="card shadow-sm">
@ -51,6 +59,27 @@
</div>
{/if}
<!-- Reverse IP Section -->
{#if receivedChain && receivedChain.length > 0}
<div class="mb-3 d-flex align-items-center gap-2">
<h4 class="mb-0">
Received by: <code>{receivedChain[0].from} ({receivedChain[0].reverse || "Unknown"} [{receivedChain[0].ip}])</code>
</h4>
</div>
{/if}
<!-- PTR Records Section -->
<PtrRecordsDisplay ptrRecords={dnsResults.ptr_records} {senderIp} />
<!-- Forward-Confirmed Reverse DNS -->
<PtrForwardRecordsDisplay
ptrRecords={dnsResults.ptr_records}
ptrForwardRecords={dnsResults.ptr_forward_records}
{senderIp}
/>
<hr class="my-4" />
<!-- Return-Path Domain Section -->
<div class="mb-3 d-flex align-items-center gap-2">
<h4 class="mb-0">

View file

@ -0,0 +1,103 @@
<script lang="ts">
interface Props {
ptrRecords?: string[];
ptrForwardRecords?: string[];
senderIp?: string;
}
let { ptrRecords, ptrForwardRecords, senderIp }: Props = $props();
// Forward-confirmed reverse DNS is valid if:
// 1. PTR records exist
// 2. Forward records exist
// 3. At least one forward record matches the original sender IP
const fcrDnsIsValid = $derived(
ptrRecords &&
ptrRecords.length > 0 &&
ptrForwardRecords &&
ptrForwardRecords.length > 0 &&
senderIp &&
ptrForwardRecords.includes(senderIp),
);
const hasForwardRecords = $derived(ptrForwardRecords && ptrForwardRecords.length > 0);
</script>
{#if ptrRecords && ptrRecords.length > 0}
<div class="card mb-4">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="text-muted mb-0">
<i
class="bi"
class:bi-check-circle-fill={fcrDnsIsValid}
class:text-success={fcrDnsIsValid}
class:bi-x-circle-fill={!fcrDnsIsValid}
class:text-danger={!fcrDnsIsValid}
></i>
Forward-Confirmed Reverse DNS
</h5>
<span class="badge bg-secondary">FCrDNS</span>
</div>
<div class="card-body">
<p class="card-text small text-muted mb-0">
Forward-confirmed reverse DNS (FCrDNS) verifies that the PTR hostname resolves back
to the original sender IP. This double-check helps establish sender legitimacy.
</p>
{#if senderIp}
<div class="mt-2">
<strong>Original Sender IP:</strong> <code>{senderIp}</code>
</div>
{/if}
</div>
{#if hasForwardRecords}
<div class="list-group list-group-flush">
<div class="list-group-item">
<div class="mb-2">
<strong>PTR Hostname(s):</strong>
{#each ptrRecords as ptr}
<div class="mt-1">
<code>{ptr}</code>
</div>
{/each}
</div>
<div class="mb-2">
<strong>Forward Resolution (A/AAAA):</strong>
{#each ptrForwardRecords as ip}
<div class="d-flex gap-2 align-items-center mt-1">
{#if senderIp && ip === senderIp}
<span class="badge bg-success">Match</span>
{:else}
<span class="badge bg-warning">Different</span>
{/if}
<code>{ip}</code>
</div>
{/each}
</div>
{#if fcrDnsIsValid}
<div class="alert alert-success mb-0 mt-2">
<i class="bi bi-check-circle me-1"></i>
<strong>Success:</strong> Forward-confirmed reverse DNS is properly configured.
The PTR hostname resolves back to the sender IP.
</div>
{:else}
<div class="alert alert-warning mb-0 mt-2">
<i class="bi bi-exclamation-triangle me-1"></i>
<strong>Warning:</strong> The PTR hostname does not resolve back to the sender
IP. This may impact deliverability.
</div>
{/if}
</div>
</div>
{:else}
<div class="list-group list-group-flush">
<div class="list-group-item">
<div class="alert alert-danger mb-0">
<i class="bi bi-x-circle me-1"></i>
<strong>Error:</strong> PTR hostname(s) found but could not resolve to any IP
addresses. Check your DNS configuration.
</div>
</div>
</div>
{/if}
</div>
{/if}

View file

@ -0,0 +1,85 @@
<script lang="ts">
interface Props {
ptrRecords?: string[];
senderIp?: string;
}
let { ptrRecords, senderIp }: Props = $props();
// PTR records are valid if at least one exists
const ptrIsValid = $derived(ptrRecords && ptrRecords.length > 0);
</script>
{#if ptrRecords && ptrRecords.length > 0}
<div class="card mb-4">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="text-muted mb-0">
<i
class="bi"
class:bi-check-circle-fill={ptrIsValid}
class:text-success={ptrIsValid}
class:bi-x-circle-fill={!ptrIsValid}
class:text-danger={!ptrIsValid}
></i>
Reverse DNS
</h5>
<span class="badge bg-secondary">PTR</span>
</div>
<div class="card-body">
<p class="card-text small text-muted mb-0">
PTR records (reverse DNS) map IP addresses back to hostnames. Having proper PTR
records is important as many mail servers verify that the sending IP has a valid
reverse DNS entry.
</p>
{#if senderIp}
<div class="mt-2">
<strong>Sender IP:</strong> <code>{senderIp}</code>
</div>
{/if}
</div>
<div class="list-group list-group-flush">
{#each ptrRecords as ptr}
<div class="list-group-item">
<div class="d-flex gap-2 align-items-center">
<span class="badge bg-success">Found</span>
<code>{ptr}</code>
</div>
</div>
{/each}
{#if ptrRecords.length > 1}
<div class="list-group-item">
<div class="alert alert-warning mb-0">
<i class="bi bi-exclamation-triangle me-1"></i>
<strong>Warning:</strong> Multiple PTR records found. While not strictly an error,
having multiple PTR records can cause issues with some mail servers. It's recommended
to have exactly one PTR record per IP address.
</div>
</div>
{/if}
</div>
</div>
{:else if senderIp}
<div class="card mb-4">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="text-muted mb-2">
<i class="bi bi-x-circle-fill text-danger"></i>
Reverse DNS (PTR)
</h5>
<span class="badge bg-secondary">PTR</span>
</div>
<div class="card-body">
<p class="card-text small text-muted mb-0">
PTR records (reverse DNS) map IP addresses back to hostnames. Having proper PTR
records is important for email deliverability.
</p>
<div class="mt-2">
<strong>Sender IP:</strong> <code>{senderIp}</code>
</div>
<div class="alert alert-danger mb-0 mt-2">
<i class="bi bi-x-circle me-1"></i>
<strong>Error:</strong> No PTR records found for the sender IP. Contact your email service
provider to configure reverse DNS.
</div>
</div>
</div>
{/if}

View file

@ -10,3 +10,5 @@ export { default as DnsRecordsCard } from "./DnsRecordsCard.svelte";
export { default as BlacklistCard } from "./BlacklistCard.svelte";
export { default as ContentAnalysisCard } from "./ContentAnalysisCard.svelte";
export { default as HeaderAnalysisCard } from "./HeaderAnalysisCard.svelte";
export { default as PtrRecordsDisplay } from "./PtrRecordsDisplay.svelte";
export { default as PtrForwardRecordsDisplay } from "./PtrForwardRecordsDisplay.svelte";

View file

@ -146,6 +146,7 @@
dnsResults={report.dns_results}
dnsGrade={report.summary?.dns_grade}
dnsScore={report.summary?.dns_score}
receivedChain={report.header_analysis?.received_chain}
/>
</div>
</div>