*/ public array $ips = []; /** @var array */ private array $visited = []; public function resolve(string $domain, int $depth = 0): array { $node = ['domain' => $domain, 'record' => null, 'error' => null, 'terms' => []]; if ($depth > 20) { $node['error'] = 'Maximum recursion depth exceeded.'; return $node; } if (isset($this->visited[$domain])) { $node['error'] = 'Already evaluated — skipped to avoid an include loop.'; return $node; } $this->visited[$domain] = true; $record = self::getSpfRecord($domain); if ($record === null) { $node['error'] = 'No "v=spf1" TXT record found.'; return $node; } $node['record'] = $record; foreach (preg_split('/\s+/', trim($record)) as $term) { if ($term === '' || strtolower($term) === 'v=spf1') { continue; } $node['terms'][] = $this->processTerm($term, $domain, $depth); } return $node; } private function processTerm(string $term, string $domain, int $depth): array { $result = ['raw' => $term, 'type' => 'unknown', 'value' => null, 'lookup' => false, 'child' => null]; $lower = strtolower($term); // Modifiers (no qualifier prefix) if (str_starts_with($lower, 'redirect=')) { $target = substr($term, 9); $this->countLookup('redirect=' . $target); $result['type'] = 'redirect'; $result['value'] = $target; $result['lookup'] = true; $result['child'] = $this->resolve($target, $depth + 1); return $result; } if (str_starts_with($lower, 'exp=')) { $result['type'] = 'exp'; $result['value'] = substr($term, 4); return $result; } // Mechanisms may carry a qualifier (+ - ~ ?) $qualifier = ''; if (strlen($term) && in_array($term[0], ['+', '-', '~', '?'], true)) { $qualifier = $term[0]; $term = substr($term, 1); $lower = strtolower($term); } $result['qualifier'] = $qualifier; if ($lower === 'all') { $result['type'] = 'all'; $result['value'] = ($qualifier ?: '+') . 'all'; return $result; } if (str_starts_with($lower, 'include:')) { $target = substr($term, 8); $this->countLookup('include:' . $target); $result['type'] = 'include'; $result['value'] = $target; $result['lookup'] = true; $result['child'] = $this->resolve($target, $depth + 1); return $result; } if ($lower === 'a' || str_starts_with($lower, 'a:') || str_starts_with($lower, 'a/')) { $this->countLookup($term); $result['type'] = 'a'; $result['value'] = $term; $result['lookup'] = true; $this->collectHostIps(self::mechanismHost($term, 'a', $domain), $term, $domain); return $result; } if ($lower === 'mx' || str_starts_with($lower, 'mx:') || str_starts_with($lower, 'mx/')) { $this->countLookup($term); $result['type'] = 'mx'; $result['value'] = $term; $result['lookup'] = true; $host = self::mechanismHost($term, 'mx', $domain); foreach ((@dns_get_record($host, DNS_MX) ?: []) as $mx) { if (!empty($mx['target'])) { $this->collectHostIps($mx['target'], $term, $domain); } } return $result; } if (str_starts_with($lower, 'ip4:') || str_starts_with($lower, 'ip6:')) { $value = substr($term, 4); $result['type'] = strtolower(substr($term, 0, 3)); $result['value'] = $value; $this->ips[] = [ 'display' => $value, 'query' => explode('/', $value)[0], 'source' => $domain, 'mechanism' => $result['type'], ]; return $result; } if ($lower === 'ptr' || str_starts_with($lower, 'ptr:')) { $this->countLookup($term); $result['type'] = 'ptr'; $result['value'] = $term; $result['lookup'] = true; return $result; } if (str_starts_with($lower, 'exists:')) { $this->countLookup($term); $result['type'] = 'exists'; $result['value'] = substr($term, 7); $result['lookup'] = true; return $result; } return $result; } private function countLookup(string $label): void { $this->lookupCount++; if ($this->lookupCount === LOOKUP_LIMIT) { $this->warnings[] = sprintf( 'Reached the RFC 7208 limit of %d DNS lookups at "%s". Any further lookup mechanism will make evaluators return a PermError.', LOOKUP_LIMIT, $label ); } elseif ($this->lookupCount > LOOKUP_LIMIT) { $this->warnings[] = sprintf( 'Exceeded the %d-lookup limit (now %d) at "%s" — this SPF record will fail with a PermError.', LOOKUP_LIMIT, $this->lookupCount, $label ); } } private function collectHostIps(string $host, string $mechanism, string $source): void { foreach (self::hostIps($host) as $ip) { $this->ips[] = [ 'display' => $ip, 'query' => $ip, 'source' => $source, 'mechanism' => $mechanism, ]; } } /** Returns the target host for an a/mx mechanism, stripping any CIDR suffix. */ private static function mechanismHost(string $term, string $name, string $domain): string { $rest = substr($term, strlen($name)); // "", ":host", "/24", ":host/24" if ($rest === '' || $rest[0] === '/') { return $domain; } $rest = ltrim($rest, ':'); return explode('/', $rest)[0]; } /** @return string[] */ private static function hostIps(string $host): array { $ips = []; foreach ((@dns_get_record($host, DNS_A) ?: []) as $r) { if (!empty($r['ip'])) { $ips[] = $r['ip']; } } foreach ((@dns_get_record($host, DNS_AAAA) ?: []) as $r) { if (!empty($r['ipv6'])) { $ips[] = $r['ipv6']; } } return $ips; } private static function getSpfRecord(string $domain): ?string { foreach ((@dns_get_record($domain, DNS_TXT) ?: []) as $r) { $txt = $r['txt'] ?? (isset($r['entries']) ? implode('', $r['entries']) : ''); if (stripos($txt, 'v=spf1') === 0) { return $txt; } } return null; } } /** * Enriches IPs with ASN / country / company via ip-api.com's free batch endpoint. * @param string[] $ips * @return array */ function lookupIpInfo(array $ips): array { $out = []; $ips = array_values(array_unique(array_filter($ips))); if (empty($ips)) { return $out; } $fields = 'query,status,message,country,countryCode,as,asname,isp,org'; foreach (array_chunk($ips, 100) as $chunk) { $payload = array_map(static fn($ip) => ['query' => $ip, 'fields' => $fields], $chunk); $ch = curl_init('http://ip-api.com/batch'); curl_setopt_array($ch, [ CURLOPT_POST => true, CURLOPT_POSTFIELDS => json_encode($payload), CURLOPT_HTTPHEADER => ['Content-Type: application/json'], CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 20, ]); $resp = curl_exec($ch); curl_close($ch); foreach ((json_decode((string) $resp, true) ?: []) as $item) { if (isset($item['query'])) { $out[$item['query']] = $item; } } } return $out; } $domain = trim((string) ($_GET['domain'] ?? '')); $resolver = null; $ipInfo = []; $inputError = null; if ($domain !== '') { // Accept a bare hostname; strip scheme/path if a URL was pasted. $domain = preg_replace('#^\w+://#', '', $domain); $domain = explode('/', $domain)[0]; $domain = strtolower(trim($domain)); if (!preg_match('/^(?=.{1,253}$)([a-z0-9](-?[a-z0-9])*\.)+[a-z]{2,}$/', $domain)) { $inputError = 'Please enter a valid domain name (e.g. example.com).'; } else { $resolver = new SpfResolver(); $tree = $resolver->resolve($domain); $ipInfo = lookupIpInfo(array_column($resolver->ips, 'query')); } } /** Renders the resolved SPF tree as nested lists. */ function renderTree(array $node): string { $h = 'htmlspecialchars'; $out = '
'; $out .= '
🌐 ' . $h($node['domain']) . '
'; if ($node['error']) { $out .= '
⚠️ ' . $h($node['error']) . '
'; return $out; } $out .= '
' . $h((string) $node['record']) . '
'; $out .= ''; return $out; } ?> SPF Record Checker

🛡️ SPF Record Checker

Resolves a domain's SPF record, follows every include: and redirect=, and enriches each authorised IP with its ASN, country and company. Counts the DNS-querying mechanisms and warns when the RFC 7208 limit of lookups is reached.
lookupCount; $counterCls = $count > LOOKUP_LIMIT ? 'over' : ($count >= LOOKUP_LIMIT ? 'warn' : ''); ?>
DNS lookups used: /
warnings as $w): ?>
⚠️

Resolution tree

Authorised IPs (ips) ?>)

ips)): ?>
No ip4/ip6/a/mx mechanisms produced any IP addresses.
ips as $entry): ?>
IP / Range Mechanism Via ASN Company / ISP Country

IP intelligence via ip-api.com (free tier).