|
|
@@ -0,0 +1,442 @@
|
|
|
+<?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>
|