| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442 |
- <?php
- declare(strict_types=1);
- const LOOKUP_LIMIT = 10; // RFC 7208 section 4.6.4 limit on DNS-querying mechanisms
- /**
- * Recursively resolves an SPF record, following include/redirect, and counts
- * the DNS-querying mechanisms toward the RFC 7208 limit of 10.
- */
- class SpfResolver
- {
- public int $lookupCount = 0;
- /** @var string[] */
- public array $warnings = [];
- /** @var array<int,array{display:string,query:string,source:string,mechanism:string}> */
- public array $ips = [];
- /** @var array<string,bool> */
- 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<string,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 = '<div class="node">';
- $out .= '<div class="node-domain">🌐 ' . $h($node['domain']) . '</div>';
- if ($node['error']) {
- $out .= '<div class="node-error">⚠️ ' . $h($node['error']) . '</div></div>';
- return $out;
- }
- $out .= '<div class="record">' . $h((string) $node['record']) . '</div>';
- $out .= '<ul class="terms">';
- foreach ($node['terms'] as $term) {
- $badge = strtoupper($h($term['type']));
- $cls = $term['lookup'] ? 'term lookup' : 'term';
- $out .= '<li class="' . $cls . '"><span class="badge badge-' . $h($term['type']) . '">' . $badge . '</span> '
- . '<code>' . $h($term['raw']) . '</code>';
- if ($term['lookup']) {
- $out .= ' <span class="lk">DNS lookup</span>';
- }
- if (!empty($term['child'])) {
- $out .= renderTree($term['child']);
- }
- $out .= '</li>';
- }
- $out .= '</ul></div>';
- return $out;
- }
- ?>
- <!DOCTYPE html>
- <html lang="en">
- <head>
- <meta charset="UTF-8">
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
- <title>SPF Record Checker</title>
- <style>
- body {
- font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
- max-width: 1200px;
- margin: 40px auto;
- padding: 0 20px;
- background: #f5f5f5;
- color: #333;
- }
- h1 { color: #333; }
- .info { background: #e3f2fd; padding: 15px; border-radius: 5px; margin-bottom: 20px; }
- form.lookup { margin-bottom: 20px; display: flex; gap: 10px; flex-wrap: wrap; }
- input[type=text] {
- flex: 1; min-width: 240px; padding: 10px; font-size: 15px;
- border: 1px solid #ccc; border-radius: 5px;
- }
- .btn {
- display: inline-block; padding: 10px 20px; background: #2196F3; color: white;
- text-decoration: none; border-radius: 5px; border: none; cursor: pointer; font-size: 15px;
- }
- .btn:hover { background: #1976D2; }
- .error { background: #ffebee; color: #c62828; padding: 10px; border-radius: 5px; margin: 10px 0; }
- .warning {
- background: #fff8e1; color: #e65100; padding: 12px 15px; border-radius: 5px;
- margin: 10px 0; border-left: 5px solid #ff9800;
- }
- .counter {
- display: inline-block; padding: 8px 14px; border-radius: 5px; font-weight: 600;
- background: #e8f5e9; color: #2e7d32;
- }
- .counter.warn { background: #fff8e1; color: #e65100; }
- .counter.over { background: #ffebee; color: #c62828; }
- .node { margin: 8px 0; }
- .node-domain { font-weight: 600; margin-top: 6px; }
- .node-error { color: #c62828; margin: 4px 0 4px 10px; }
- .record {
- background: #263238; color: #aed581; padding: 8px 12px; border-radius: 4px;
- font-family: monospace; font-size: 13px; word-break: break-all; margin: 4px 0;
- }
- ul.terms { list-style: none; margin: 4px 0 4px 8px; padding-left: 16px; border-left: 2px solid #e0e0e0; }
- .term { margin: 4px 0; }
- .term code { background: #eceff1; padding: 2px 6px; border-radius: 3px; font-size: 13px; }
- .badge {
- display: inline-block; min-width: 54px; text-align: center; padding: 2px 6px;
- border-radius: 3px; font-size: 11px; font-weight: 700; color: white; background: #90a4ae;
- }
- .badge-include, .badge-redirect { background: #5c6bc0; }
- .badge-ip4, .badge-ip6 { background: #26a69a; }
- .badge-a, .badge-mx { background: #ec407a; }
- .badge-all { background: #78909c; }
- .badge-exists, .badge-ptr { background: #ab47bc; }
- .lk { font-size: 11px; color: #ef6c00; font-weight: 600; }
- table { width: 100%; border-collapse: collapse; background: white; box-shadow: 0 1px 3px rgba(0,0,0,0.1); margin-top: 10px; }
- th, td { padding: 10px 12px; text-align: left; border-bottom: 1px solid #ddd; font-size: 13px; }
- th { background: #2196F3; color: white; }
- tr:hover { background: #f5f5f5; }
- td.ip { font-family: monospace; }
- h2 { color: #333; margin-top: 30px; }
- .empty { text-align: center; color: #999; padding: 40px; }
- </style>
- </head>
- <body>
- <h1>🛡️ SPF Record Checker</h1>
- <div class="info">
- Resolves a domain's <strong>SPF</strong> record, follows every <code>include:</code> and
- <code>redirect=</code>, and enriches each authorised IP with its <strong>ASN, country and company</strong>.
- Counts the DNS-querying mechanisms and warns when the
- <strong>RFC 7208 limit of <?= LOOKUP_LIMIT ?> lookups</strong> is reached.
- </div>
- <form class="lookup" method="GET">
- <input type="text" name="domain" placeholder="example.com" value="<?= htmlspecialchars($domain) ?>" autofocus>
- <button type="submit" class="btn">🔍 Check SPF</button>
- </form>
- <?php if ($inputError): ?>
- <div class="error"><?= htmlspecialchars($inputError) ?></div>
- <?php endif; ?>
- <?php if ($resolver !== null): ?>
- <?php
- $count = $resolver->lookupCount;
- $counterCls = $count > LOOKUP_LIMIT ? 'over' : ($count >= LOOKUP_LIMIT ? 'warn' : '');
- ?>
- <div style="margin: 16px 0;">
- <span class="counter <?= $counterCls ?>">
- DNS lookups used: <?= $count ?> / <?= LOOKUP_LIMIT ?>
- </span>
- </div>
- <?php foreach ($resolver->warnings as $w): ?>
- <div class="warning">⚠️ <?= htmlspecialchars($w) ?></div>
- <?php endforeach; ?>
- <h2>Resolution tree</h2>
- <?= renderTree($tree) ?>
- <h2>Authorised IPs (<?= count($resolver->ips) ?>)</h2>
- <?php if (empty($resolver->ips)): ?>
- <div class="empty">No ip4/ip6/a/mx mechanisms produced any IP addresses.</div>
- <?php else: ?>
- <table>
- <thead>
- <tr>
- <th>IP / Range</th>
- <th>Mechanism</th>
- <th>Via</th>
- <th>ASN</th>
- <th>Company / ISP</th>
- <th>Country</th>
- </tr>
- </thead>
- <tbody>
- <?php foreach ($resolver->ips as $entry): ?>
- <?php
- $info = $ipInfo[$entry['query']] ?? null;
- $ok = $info && ($info['status'] ?? '') === 'success';
- $asn = $ok ? ($info['as'] ?: '—') : '—';
- $company = $ok ? ($info['org'] ?: ($info['isp'] ?? '') ?: ($info['asname'] ?? '')) : '';
- $country = $ok ? trim(($info['country'] ?? '') . ' (' . ($info['countryCode'] ?? '') . ')', ' ()') : '';
- ?>
- <tr>
- <td class="ip"><?= htmlspecialchars($entry['display']) ?></td>
- <td><span class="badge badge-<?= htmlspecialchars($entry['mechanism']) ?>"><?= htmlspecialchars(strtoupper($entry['mechanism'])) ?></span></td>
- <td><?= htmlspecialchars($entry['source']) ?></td>
- <td><?= htmlspecialchars($asn) ?></td>
- <td><?= htmlspecialchars($company ?: '—') ?></td>
- <td><?= htmlspecialchars($country ?: '—') ?></td>
- </tr>
- <?php endforeach; ?>
- </tbody>
- </table>
- <p style="color:#888;font-size:12px;">IP intelligence via ip-api.com (free tier).</p>
- <?php endif; ?>
- <?php endif; ?>
- </body>
- </html>
|