spf-check.php 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442
  1. <?php
  2. declare(strict_types=1);
  3. const LOOKUP_LIMIT = 10; // RFC 7208 section 4.6.4 limit on DNS-querying mechanisms
  4. /**
  5. * Recursively resolves an SPF record, following include/redirect, and counts
  6. * the DNS-querying mechanisms toward the RFC 7208 limit of 10.
  7. */
  8. class SpfResolver
  9. {
  10. public int $lookupCount = 0;
  11. /** @var string[] */
  12. public array $warnings = [];
  13. /** @var array<int,array{display:string,query:string,source:string,mechanism:string}> */
  14. public array $ips = [];
  15. /** @var array<string,bool> */
  16. private array $visited = [];
  17. public function resolve(string $domain, int $depth = 0): array
  18. {
  19. $node = ['domain' => $domain, 'record' => null, 'error' => null, 'terms' => []];
  20. if ($depth > 20) {
  21. $node['error'] = 'Maximum recursion depth exceeded.';
  22. return $node;
  23. }
  24. if (isset($this->visited[$domain])) {
  25. $node['error'] = 'Already evaluated — skipped to avoid an include loop.';
  26. return $node;
  27. }
  28. $this->visited[$domain] = true;
  29. $record = self::getSpfRecord($domain);
  30. if ($record === null) {
  31. $node['error'] = 'No "v=spf1" TXT record found.';
  32. return $node;
  33. }
  34. $node['record'] = $record;
  35. foreach (preg_split('/\s+/', trim($record)) as $term) {
  36. if ($term === '' || strtolower($term) === 'v=spf1') {
  37. continue;
  38. }
  39. $node['terms'][] = $this->processTerm($term, $domain, $depth);
  40. }
  41. return $node;
  42. }
  43. private function processTerm(string $term, string $domain, int $depth): array
  44. {
  45. $result = ['raw' => $term, 'type' => 'unknown', 'value' => null, 'lookup' => false, 'child' => null];
  46. $lower = strtolower($term);
  47. // Modifiers (no qualifier prefix)
  48. if (str_starts_with($lower, 'redirect=')) {
  49. $target = substr($term, 9);
  50. $this->countLookup('redirect=' . $target);
  51. $result['type'] = 'redirect';
  52. $result['value'] = $target;
  53. $result['lookup'] = true;
  54. $result['child'] = $this->resolve($target, $depth + 1);
  55. return $result;
  56. }
  57. if (str_starts_with($lower, 'exp=')) {
  58. $result['type'] = 'exp';
  59. $result['value'] = substr($term, 4);
  60. return $result;
  61. }
  62. // Mechanisms may carry a qualifier (+ - ~ ?)
  63. $qualifier = '';
  64. if (strlen($term) && in_array($term[0], ['+', '-', '~', '?'], true)) {
  65. $qualifier = $term[0];
  66. $term = substr($term, 1);
  67. $lower = strtolower($term);
  68. }
  69. $result['qualifier'] = $qualifier;
  70. if ($lower === 'all') {
  71. $result['type'] = 'all';
  72. $result['value'] = ($qualifier ?: '+') . 'all';
  73. return $result;
  74. }
  75. if (str_starts_with($lower, 'include:')) {
  76. $target = substr($term, 8);
  77. $this->countLookup('include:' . $target);
  78. $result['type'] = 'include';
  79. $result['value'] = $target;
  80. $result['lookup'] = true;
  81. $result['child'] = $this->resolve($target, $depth + 1);
  82. return $result;
  83. }
  84. if ($lower === 'a' || str_starts_with($lower, 'a:') || str_starts_with($lower, 'a/')) {
  85. $this->countLookup($term);
  86. $result['type'] = 'a';
  87. $result['value'] = $term;
  88. $result['lookup'] = true;
  89. $this->collectHostIps(self::mechanismHost($term, 'a', $domain), $term, $domain);
  90. return $result;
  91. }
  92. if ($lower === 'mx' || str_starts_with($lower, 'mx:') || str_starts_with($lower, 'mx/')) {
  93. $this->countLookup($term);
  94. $result['type'] = 'mx';
  95. $result['value'] = $term;
  96. $result['lookup'] = true;
  97. $host = self::mechanismHost($term, 'mx', $domain);
  98. foreach ((@dns_get_record($host, DNS_MX) ?: []) as $mx) {
  99. if (!empty($mx['target'])) {
  100. $this->collectHostIps($mx['target'], $term, $domain);
  101. }
  102. }
  103. return $result;
  104. }
  105. if (str_starts_with($lower, 'ip4:') || str_starts_with($lower, 'ip6:')) {
  106. $value = substr($term, 4);
  107. $result['type'] = strtolower(substr($term, 0, 3));
  108. $result['value'] = $value;
  109. $this->ips[] = [
  110. 'display' => $value,
  111. 'query' => explode('/', $value)[0],
  112. 'source' => $domain,
  113. 'mechanism' => $result['type'],
  114. ];
  115. return $result;
  116. }
  117. if ($lower === 'ptr' || str_starts_with($lower, 'ptr:')) {
  118. $this->countLookup($term);
  119. $result['type'] = 'ptr';
  120. $result['value'] = $term;
  121. $result['lookup'] = true;
  122. return $result;
  123. }
  124. if (str_starts_with($lower, 'exists:')) {
  125. $this->countLookup($term);
  126. $result['type'] = 'exists';
  127. $result['value'] = substr($term, 7);
  128. $result['lookup'] = true;
  129. return $result;
  130. }
  131. return $result;
  132. }
  133. private function countLookup(string $label): void
  134. {
  135. $this->lookupCount++;
  136. if ($this->lookupCount === LOOKUP_LIMIT) {
  137. $this->warnings[] = sprintf(
  138. 'Reached the RFC 7208 limit of %d DNS lookups at "%s". Any further lookup mechanism will make evaluators return a PermError.',
  139. LOOKUP_LIMIT,
  140. $label
  141. );
  142. } elseif ($this->lookupCount > LOOKUP_LIMIT) {
  143. $this->warnings[] = sprintf(
  144. 'Exceeded the %d-lookup limit (now %d) at "%s" — this SPF record will fail with a PermError.',
  145. LOOKUP_LIMIT,
  146. $this->lookupCount,
  147. $label
  148. );
  149. }
  150. }
  151. private function collectHostIps(string $host, string $mechanism, string $source): void
  152. {
  153. foreach (self::hostIps($host) as $ip) {
  154. $this->ips[] = [
  155. 'display' => $ip,
  156. 'query' => $ip,
  157. 'source' => $source,
  158. 'mechanism' => $mechanism,
  159. ];
  160. }
  161. }
  162. /** Returns the target host for an a/mx mechanism, stripping any CIDR suffix. */
  163. private static function mechanismHost(string $term, string $name, string $domain): string
  164. {
  165. $rest = substr($term, strlen($name)); // "", ":host", "/24", ":host/24"
  166. if ($rest === '' || $rest[0] === '/') {
  167. return $domain;
  168. }
  169. $rest = ltrim($rest, ':');
  170. return explode('/', $rest)[0];
  171. }
  172. /** @return string[] */
  173. private static function hostIps(string $host): array
  174. {
  175. $ips = [];
  176. foreach ((@dns_get_record($host, DNS_A) ?: []) as $r) {
  177. if (!empty($r['ip'])) {
  178. $ips[] = $r['ip'];
  179. }
  180. }
  181. foreach ((@dns_get_record($host, DNS_AAAA) ?: []) as $r) {
  182. if (!empty($r['ipv6'])) {
  183. $ips[] = $r['ipv6'];
  184. }
  185. }
  186. return $ips;
  187. }
  188. private static function getSpfRecord(string $domain): ?string
  189. {
  190. foreach ((@dns_get_record($domain, DNS_TXT) ?: []) as $r) {
  191. $txt = $r['txt'] ?? (isset($r['entries']) ? implode('', $r['entries']) : '');
  192. if (stripos($txt, 'v=spf1') === 0) {
  193. return $txt;
  194. }
  195. }
  196. return null;
  197. }
  198. }
  199. /**
  200. * Enriches IPs with ASN / country / company via ip-api.com's free batch endpoint.
  201. * @param string[] $ips
  202. * @return array<string,array>
  203. */
  204. function lookupIpInfo(array $ips): array
  205. {
  206. $out = [];
  207. $ips = array_values(array_unique(array_filter($ips)));
  208. if (empty($ips)) {
  209. return $out;
  210. }
  211. $fields = 'query,status,message,country,countryCode,as,asname,isp,org';
  212. foreach (array_chunk($ips, 100) as $chunk) {
  213. $payload = array_map(static fn($ip) => ['query' => $ip, 'fields' => $fields], $chunk);
  214. $ch = curl_init('http://ip-api.com/batch');
  215. curl_setopt_array($ch, [
  216. CURLOPT_POST => true,
  217. CURLOPT_POSTFIELDS => json_encode($payload),
  218. CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
  219. CURLOPT_RETURNTRANSFER => true,
  220. CURLOPT_TIMEOUT => 20,
  221. ]);
  222. $resp = curl_exec($ch);
  223. curl_close($ch);
  224. foreach ((json_decode((string) $resp, true) ?: []) as $item) {
  225. if (isset($item['query'])) {
  226. $out[$item['query']] = $item;
  227. }
  228. }
  229. }
  230. return $out;
  231. }
  232. $domain = trim((string) ($_GET['domain'] ?? ''));
  233. $resolver = null;
  234. $ipInfo = [];
  235. $inputError = null;
  236. if ($domain !== '') {
  237. // Accept a bare hostname; strip scheme/path if a URL was pasted.
  238. $domain = preg_replace('#^\w+://#', '', $domain);
  239. $domain = explode('/', $domain)[0];
  240. $domain = strtolower(trim($domain));
  241. if (!preg_match('/^(?=.{1,253}$)([a-z0-9](-?[a-z0-9])*\.)+[a-z]{2,}$/', $domain)) {
  242. $inputError = 'Please enter a valid domain name (e.g. example.com).';
  243. } else {
  244. $resolver = new SpfResolver();
  245. $tree = $resolver->resolve($domain);
  246. $ipInfo = lookupIpInfo(array_column($resolver->ips, 'query'));
  247. }
  248. }
  249. /** Renders the resolved SPF tree as nested lists. */
  250. function renderTree(array $node): string
  251. {
  252. $h = 'htmlspecialchars';
  253. $out = '<div class="node">';
  254. $out .= '<div class="node-domain">🌐 ' . $h($node['domain']) . '</div>';
  255. if ($node['error']) {
  256. $out .= '<div class="node-error">⚠️ ' . $h($node['error']) . '</div></div>';
  257. return $out;
  258. }
  259. $out .= '<div class="record">' . $h((string) $node['record']) . '</div>';
  260. $out .= '<ul class="terms">';
  261. foreach ($node['terms'] as $term) {
  262. $badge = strtoupper($h($term['type']));
  263. $cls = $term['lookup'] ? 'term lookup' : 'term';
  264. $out .= '<li class="' . $cls . '"><span class="badge badge-' . $h($term['type']) . '">' . $badge . '</span> '
  265. . '<code>' . $h($term['raw']) . '</code>';
  266. if ($term['lookup']) {
  267. $out .= ' <span class="lk">DNS lookup</span>';
  268. }
  269. if (!empty($term['child'])) {
  270. $out .= renderTree($term['child']);
  271. }
  272. $out .= '</li>';
  273. }
  274. $out .= '</ul></div>';
  275. return $out;
  276. }
  277. ?>
  278. <!DOCTYPE html>
  279. <html lang="en">
  280. <head>
  281. <meta charset="UTF-8">
  282. <meta name="viewport" content="width=device-width, initial-scale=1.0">
  283. <title>SPF Record Checker</title>
  284. <style>
  285. body {
  286. font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
  287. max-width: 1200px;
  288. margin: 40px auto;
  289. padding: 0 20px;
  290. background: #f5f5f5;
  291. color: #333;
  292. }
  293. h1 { color: #333; }
  294. .info { background: #e3f2fd; padding: 15px; border-radius: 5px; margin-bottom: 20px; }
  295. form.lookup { margin-bottom: 20px; display: flex; gap: 10px; flex-wrap: wrap; }
  296. input[type=text] {
  297. flex: 1; min-width: 240px; padding: 10px; font-size: 15px;
  298. border: 1px solid #ccc; border-radius: 5px;
  299. }
  300. .btn {
  301. display: inline-block; padding: 10px 20px; background: #2196F3; color: white;
  302. text-decoration: none; border-radius: 5px; border: none; cursor: pointer; font-size: 15px;
  303. }
  304. .btn:hover { background: #1976D2; }
  305. .error { background: #ffebee; color: #c62828; padding: 10px; border-radius: 5px; margin: 10px 0; }
  306. .warning {
  307. background: #fff8e1; color: #e65100; padding: 12px 15px; border-radius: 5px;
  308. margin: 10px 0; border-left: 5px solid #ff9800;
  309. }
  310. .counter {
  311. display: inline-block; padding: 8px 14px; border-radius: 5px; font-weight: 600;
  312. background: #e8f5e9; color: #2e7d32;
  313. }
  314. .counter.warn { background: #fff8e1; color: #e65100; }
  315. .counter.over { background: #ffebee; color: #c62828; }
  316. .node { margin: 8px 0; }
  317. .node-domain { font-weight: 600; margin-top: 6px; }
  318. .node-error { color: #c62828; margin: 4px 0 4px 10px; }
  319. .record {
  320. background: #263238; color: #aed581; padding: 8px 12px; border-radius: 4px;
  321. font-family: monospace; font-size: 13px; word-break: break-all; margin: 4px 0;
  322. }
  323. ul.terms { list-style: none; margin: 4px 0 4px 8px; padding-left: 16px; border-left: 2px solid #e0e0e0; }
  324. .term { margin: 4px 0; }
  325. .term code { background: #eceff1; padding: 2px 6px; border-radius: 3px; font-size: 13px; }
  326. .badge {
  327. display: inline-block; min-width: 54px; text-align: center; padding: 2px 6px;
  328. border-radius: 3px; font-size: 11px; font-weight: 700; color: white; background: #90a4ae;
  329. }
  330. .badge-include, .badge-redirect { background: #5c6bc0; }
  331. .badge-ip4, .badge-ip6 { background: #26a69a; }
  332. .badge-a, .badge-mx { background: #ec407a; }
  333. .badge-all { background: #78909c; }
  334. .badge-exists, .badge-ptr { background: #ab47bc; }
  335. .lk { font-size: 11px; color: #ef6c00; font-weight: 600; }
  336. table { width: 100%; border-collapse: collapse; background: white; box-shadow: 0 1px 3px rgba(0,0,0,0.1); margin-top: 10px; }
  337. th, td { padding: 10px 12px; text-align: left; border-bottom: 1px solid #ddd; font-size: 13px; }
  338. th { background: #2196F3; color: white; }
  339. tr:hover { background: #f5f5f5; }
  340. td.ip { font-family: monospace; }
  341. h2 { color: #333; margin-top: 30px; }
  342. .empty { text-align: center; color: #999; padding: 40px; }
  343. </style>
  344. </head>
  345. <body>
  346. <h1>🛡️ SPF Record Checker</h1>
  347. <div class="info">
  348. Resolves a domain's <strong>SPF</strong> record, follows every <code>include:</code> and
  349. <code>redirect=</code>, and enriches each authorised IP with its <strong>ASN, country and company</strong>.
  350. Counts the DNS-querying mechanisms and warns when the
  351. <strong>RFC 7208 limit of <?= LOOKUP_LIMIT ?> lookups</strong> is reached.
  352. </div>
  353. <form class="lookup" method="GET">
  354. <input type="text" name="domain" placeholder="example.com" value="<?= htmlspecialchars($domain) ?>" autofocus>
  355. <button type="submit" class="btn">🔍 Check SPF</button>
  356. </form>
  357. <?php if ($inputError): ?>
  358. <div class="error"><?= htmlspecialchars($inputError) ?></div>
  359. <?php endif; ?>
  360. <?php if ($resolver !== null): ?>
  361. <?php
  362. $count = $resolver->lookupCount;
  363. $counterCls = $count > LOOKUP_LIMIT ? 'over' : ($count >= LOOKUP_LIMIT ? 'warn' : '');
  364. ?>
  365. <div style="margin: 16px 0;">
  366. <span class="counter <?= $counterCls ?>">
  367. DNS lookups used: <?= $count ?> / <?= LOOKUP_LIMIT ?>
  368. </span>
  369. </div>
  370. <?php foreach ($resolver->warnings as $w): ?>
  371. <div class="warning">⚠️ <?= htmlspecialchars($w) ?></div>
  372. <?php endforeach; ?>
  373. <h2>Resolution tree</h2>
  374. <?= renderTree($tree) ?>
  375. <h2>Authorised IPs (<?= count($resolver->ips) ?>)</h2>
  376. <?php if (empty($resolver->ips)): ?>
  377. <div class="empty">No ip4/ip6/a/mx mechanisms produced any IP addresses.</div>
  378. <?php else: ?>
  379. <table>
  380. <thead>
  381. <tr>
  382. <th>IP / Range</th>
  383. <th>Mechanism</th>
  384. <th>Via</th>
  385. <th>ASN</th>
  386. <th>Company / ISP</th>
  387. <th>Country</th>
  388. </tr>
  389. </thead>
  390. <tbody>
  391. <?php foreach ($resolver->ips as $entry): ?>
  392. <?php
  393. $info = $ipInfo[$entry['query']] ?? null;
  394. $ok = $info && ($info['status'] ?? '') === 'success';
  395. $asn = $ok ? ($info['as'] ?: '—') : '—';
  396. $company = $ok ? ($info['org'] ?: ($info['isp'] ?? '') ?: ($info['asname'] ?? '')) : '';
  397. $country = $ok ? trim(($info['country'] ?? '') . ' (' . ($info['countryCode'] ?? '') . ')', ' ()') : '';
  398. ?>
  399. <tr>
  400. <td class="ip"><?= htmlspecialchars($entry['display']) ?></td>
  401. <td><span class="badge badge-<?= htmlspecialchars($entry['mechanism']) ?>"><?= htmlspecialchars(strtoupper($entry['mechanism'])) ?></span></td>
  402. <td><?= htmlspecialchars($entry['source']) ?></td>
  403. <td><?= htmlspecialchars($asn) ?></td>
  404. <td><?= htmlspecialchars($company ?: '—') ?></td>
  405. <td><?= htmlspecialchars($country ?: '—') ?></td>
  406. </tr>
  407. <?php endforeach; ?>
  408. </tbody>
  409. </table>
  410. <p style="color:#888;font-size:12px;">IP intelligence via ip-api.com (free tier).</p>
  411. <?php endif; ?>
  412. <?php endif; ?>
  413. </body>
  414. </html>