the reverse-DNS domain * suffixes an IP claiming it must resolve into. Sourced from the owner-editable * {@see VerifierRegistry} (its rDNS half), so disabling a built-in or adding a * custom entry changes every consumer of this map at once. Filterable, so a site * can still add its CDN's own verified crawler or tighten the set in code. * * @return array */ public static function engine_domains() { $map = VerifierRegistry::rdns_map(); /** * Filter the verifiable-engine map (UA token => rDNS domain suffixes). * * @param array $map Engine token => domain suffixes. */ return (array) apply_filters( 'agentimus_verified_bot_domains', $map ); } /** * The engine a lowercased UA claims, for verification, or '' when it claims none we * can verify. Pure. * * @param string $ua_lc Lowercased User-Agent. * @return string Engine token, or ''. */ public static function claimed_engine( $ua_lc ) { // Resolve through the registry's UA needles, not the map's tokens: for built-ins // they're identical, but a custom entry's token (c_*) never appears in a UA. $token = VerifierRegistry::claimed( (string) $ua_lc ); if ( '' === $token ) { return ''; } $all = self::engine_domains(); return isset( $all[ $token ] ) ? $token : ''; // rDNS-verifiable entries only. } /** * Tri-state verdict for the engine a UA claims. true = forward-confirmed; * false = conclusively not that engine; null = could not determine (fail open). * * @param string $ua_lc Lowercased User-Agent. * @param string $ip Source IP. * @return bool|null */ public static function verify_engine( $ua_lc, $ip ) { $engine = self::claimed_engine( $ua_lc ); if ( '' === $engine ) { // Not a claim this map can verify → INDETERMINATE, never "conclusively fake". // The map is owner-editable now: removing Googlebot from the registry must // make it unverifiable, not strip the real crawler's always-allow (the Guard // only acts on a conclusive false). return null; } if ( '' === (string) $ip ) { return null; // No IP to check → cannot determine → keep the crawler protected. } $all = self::engine_domains(); $domains = isset( $all[ $engine ] ) ? (array) $all[ $engine ] : array(); return self::verify_ip( (string) $ip, $domains ); } /** * Cached tri-state FCrDNS verdict for $ip against $domains. Bounded by the per-window * budget and the circuit breaker — both of which fail OPEN (null), never closed. * * @param string $ip Source IP. * @param string[] $domains Expected rDNS domain suffixes. * @return bool|null */ public static function verify_ip( $ip, array $domains ) { $key = self::CACHE_PREFIX . md5( $ip . '|' . implode( ',', $domains ) ); $cached = get_transient( $key ); if ( 'v' === $cached ) { return true; } if ( 's' === $cached ) { return false; } // Circuit breaker open (the resolver has been slow/failing) → don't touch DNS; // fail OPEN so a broken resolver can't keep stalling requests or lose crawlers. if ( get_transient( self::TRIP_KEY ) ) { return null; } // Past the anti-amplification budget → don't perform a NEW lookup; fail OPEN. if ( ! self::spend_lookup() ) { return null; } $verdict = self::fcrdns( $ip, $domains ); // true | false | null // Cache CONCLUSIVE verdicts only. An inconclusive (null) result is never cached, // so a transient DNS failure is not remembered as a lasting "unverified". if ( true === $verdict ) { set_transient( $key, 'v', self::CACHE_TTL ); } elseif ( false === $verdict ) { set_transient( $key, 's', self::CACHE_TTL ); } return $verdict; } /** * Admin-initiated re-check of one IP: force a FRESH forward-confirmed reverse-DNS lookup, * bypassing the per-window NEW-lookup budget. That budget guards the unauthenticated serve * path against a DNS storm; a capability-gated admin click is not that threat model, so it * spends none of it. The per-IP cache is busted first, so a re-check never just replays a * stale verdict. The slow-lookup guard and circuit-breaker strikes inside {@see fcrdns()} * still apply, so a genuinely broken resolver still fails OPEN (null) rather than hanging. * The fresh CONCLUSIVE verdict is re-cached, so the next serve-path hit reflects it at once. * * @param string $ip Source IP. * @param string[] $domains Expected rDNS domain suffixes. * @return bool|null true = forward-confirmed, false = conclusive spoof, null = undetermined. */ public static function reverify_ip( $ip, array $domains ) { if ( '' === (string) $ip ) { return null; } $key = self::CACHE_PREFIX . md5( $ip . '|' . implode( ',', $domains ) ); delete_transient( $key ); // Never answer a re-check from the stale cached verdict. $verdict = self::fcrdns( (string) $ip, $domains ); // true | false | null — no budget gate. if ( true === $verdict ) { set_transient( $key, 'v', self::CACHE_TTL ); } elseif ( false === $verdict ) { set_transient( $key, 's', self::CACHE_TTL ); } return $verdict; } /** * Admin re-check wrapper mirroring {@see verify_engine()}: resolve the engine a UA claims, * then re-run FCrDNS for $ip against that engine's domains. false when the UA claims no * engine we can verify; null when there's no IP; else the tri-state {@see reverify_ip()}. * * @param string $ua_lc Lowercased User-Agent. * @param string $ip Source IP. * @return bool|null */ public static function reverify_engine( $ua_lc, $ip ) { $engine = self::claimed_engine( $ua_lc ); if ( '' === $engine ) { return null; // No rDNS claim in the (editable) registry — indeterminate, as above. } if ( '' === (string) $ip ) { return null; } $all = self::engine_domains(); return self::reverify_ip( (string) $ip, isset( $all[ $engine ] ) ? (array) $all[ $engine ] : array() ); } /** * Reverse-resolve an IP into its owning network and (for a verifiable engine) verdict — the * shared core behind {@see identify_ip()} (admin tool) and {@see attribute_ip()} (serve path). * Records a slow-lookup strike and returns `ok:false` on a slow reverse/forward lookup, so a * broken resolver fails open at every caller. * * @param string $ip Source IP (already validated by the caller). * @return array{host:string,network:string,engine:string,verdict:int,ok:bool} host = the PTR * hostname; network = its registrable domain (the org, IP-encoding labels dropped); verdict: * 1 = forward-confirmed engine, 2 = forged engine PTR, 0 = no engine / no PTR; ok = the * lookup completed promptly (false when a lookup was too slow — an undetermined result). */ private static function resolve_attribution( $ip ) { $out = array( 'host' => '', 'network' => '', 'engine' => '', 'verdict' => 0, 'ok' => true, ); $t0 = microtime( true ); $host = strtolower( rtrim( self::reverse( $ip ), '.' ) ); if ( self::too_slow( $t0 ) ) { self::record_strike(); $out['ok'] = false; return $out; } if ( '' === $host || $host === strtolower( (string) $ip ) ) { return $out; // Conclusive: no usable PTR. } $out['host'] = $host; $out['network'] = self::network_from_host( $host ); // Which verifiable engine, if any, does the PTR host belong to? foreach ( self::engine_domains() as $token => $suffixes ) { if ( self::host_in_domains( $host, (array) $suffixes ) ) { $out['engine'] = (string) $token; break; } } if ( '' === $out['engine'] ) { return $out; // Attributed to a network, but no engine claim to forward-confirm. } // Forward-confirm: the PTR host must resolve back to this IP. $t1 = microtime( true ); $ips = self::forward( $host ); if ( self::too_slow( $t1 ) ) { self::record_strike(); $out['ok'] = false; return $out; } foreach ( $ips as $resolved ) { if ( self::same_ip( $resolved, $ip ) ) { $out['verdict'] = 1; // Genuine. return $out; } } // Claimed an engine's domain but didn't forward-confirm → forged PTR, but only when we // actually resolved addresses of this IP's family (else we couldn't really check). $out['verdict'] = self::forward_had_family( $ips, $ip ) ? 2 : 0; return $out; } /** * The registrable ("network") domain of a PTR hostname — the org that owns the IP, with the * IP-encoding subdomain labels dropped, so `ec2-52-1-2-3.compute.amazonaws.com` → `amazonaws.com` * and `crawl-66-249-66-1.googlebot.com` → `googlebot.com`. This is what "identify" stores: the * network is org-level, NOT the address (unlike the raw PTR, which encodes the IP for many hosts). * A short two-level-TLD list keeps `foo.co.uk` from collapsing to `co.uk`. Pure. * * @param string $host Lowercased PTR hostname (no trailing dot). * @return string Registrable domain, or '' when not a usable hostname. */ public static function network_from_host( $host ) { $host = strtolower( trim( (string) $host, " \t." ) ); if ( '' === $host || false !== strpos( $host, ' ' ) || false === strpos( $host, '.' ) ) { return ''; } $labels = explode( '.', $host ); $n = count( $labels ); if ( $n <= 2 ) { return $host; } $two_level = array( 'co.uk', 'org.uk', 'com.au', 'net.au', 'co.jp', 'co.nz', 'co.za', 'com.br', 'co.in', 'com.cn', 'co.kr', 'com.mx', 'com.tr', 'com.sg', 'com.hk', ); $last2 = $labels[ $n - 2 ] . '.' . $labels[ $n - 1 ]; if ( in_array( $last2, $two_level, true ) && $n >= 3 ) { return $labels[ $n - 3 ] . '.' . $last2; } return $last2; } /** * Serve-path attribution for the opt-in "identify every bot" mode: reverse-resolve a hit's IP * to its owning network (+ verdict for a verifiable engine), CACHED per IP and bounded by the * same anti-amplification budget and circuit breaker as {@see verify_ip()}. FAILS OPEN — a spent * budget, an open breaker, or a slow lookup returns an EMPTY attribution (no network), never a * wrong one and never a stall. The raw IP is never returned to the caller; only the network is. * * @param string $ip Source IP. * @return array{host:string,network:string,engine:string,verdict:int} */ public static function attribute_ip( $ip ) { $none = array( 'host' => '', 'network' => '', 'engine' => '', 'verdict' => 0, ); $ip = (string) $ip; if ( '' === $ip || false === filter_var( $ip, FILTER_VALIDATE_IP ) ) { return $none; } $key = self::ATTR_PREFIX . md5( $ip ); $cached = get_transient( $key ); if ( is_array( $cached ) ) { return $cached; } // Circuit breaker open / budget spent → don't touch DNS; fail OPEN (no attribution). if ( get_transient( self::TRIP_KEY ) || ! self::spend_lookup() ) { return $none; } $r = self::resolve_attribution( $ip ); $ok = $r['ok']; unset( $r['ok'] ); // Cache only a promptly-completed lookup; a slow/undetermined one is NOT cached, so a // transient DNS hiccup is never remembered as "no network". if ( $ok ) { set_transient( $key, $r, self::CACHE_TTL ); } return $r; } /** * Engine-agnostic identity of an IP, for the admin "Check an IP" tool: reverse-resolve it, * report its owning network, and (when its PTR belongs to a verifiable engine) forward-confirm. * Unlike the serve path it skips the budget (a capability-gated admin action isn't the * amplification threat), but keeps the slow-guard. * * @param string $ip Source IP. * @return array{ip:string,host:string,network:string,engine:string,verdict:int,slow:bool} */ public static function identify_ip( $ip ) { $ip = (string) $ip; if ( '' === $ip || false === filter_var( $ip, FILTER_VALIDATE_IP ) ) { return array( 'ip' => $ip, 'host' => '', 'network' => '', 'engine' => '', 'verdict' => 0, 'slow' => false, ); } $r = self::resolve_attribution( $ip ); return array( 'ip' => $ip, 'host' => $r['host'], 'network' => $r['network'], 'engine' => $r['engine'], 'verdict' => $r['verdict'], 'slow' => ! $r['ok'], ); } /** * The forward-confirmed reverse-DNS check, three-valued and time-bounded. A slow * reverse or forward lookup records a strike and returns null (never proceeds to * stack a second slow lookup); a non-match is only called a spoof (false) when we * actually resolved addresses of the client IP's family, else it stays null. * * @param string $ip Source IP. * @param string[] $domains Expected rDNS domain suffixes. * @return bool|null */ private static function fcrdns( $ip, array $domains ) { $t0 = microtime( true ); $host = strtolower( rtrim( self::reverse( $ip ), '.' ) ); if ( self::too_slow( $t0 ) ) { self::record_strike(); return null; // Slow reverse lookup → keep the crawler; retry later. } if ( '' === $host || $host === strtolower( (string) $ip ) ) { return false; // Resolver answered promptly with no usable PTR → conclusive. } if ( ! self::host_in_domains( $host, $domains ) ) { return false; // PTR points to another domain → conclusive. } // Forward-confirm: the PTR hostname must resolve back to the same IP. $t1 = microtime( true ); $ips = self::forward( $host ); if ( self::too_slow( $t1 ) ) { self::record_strike(); return null; // Slow forward lookup → keep the crawler. } foreach ( $ips as $resolved ) { if ( self::same_ip( $resolved, $ip ) ) { return true; } } // No match. Only a fake PTR (conclusive spoof) when the hostname actually // resolved to addresses of the client's family; if we got none of that family // (e.g. an IPv6 client on a host that can't resolve AAAA) we could not really // check — stay inconclusive and keep the crawler. return self::forward_had_family( $ips, $ip ) ? false : null; } /** * Whether $host is, or is a subdomain of, one of the domain suffixes. Each suffix * carries a leading dot, so a look-alike like "evil-googlebot.com" cannot match * ".googlebot.com". Pure. * * @param string $host Lowercased hostname (no trailing dot). * @param string[] $domains Domain suffixes, each e.g. ".googlebot.com". * @return bool */ public static function host_in_domains( $host, array $domains ) { $host = strtolower( (string) $host ); foreach ( $domains as $suffix ) { $s = strtolower( (string) $suffix ); if ( '' === $s ) { continue; } $bare = ltrim( $s, '.' ); if ( $host === $bare ) { return true; // Exact apex, e.g. host = googlebot.com. } // A real subdomain: ends with ".googlebot.com" AND has a label before the dot. if ( strlen( $host ) > strlen( $s ) && substr( $host, -strlen( $s ) ) === $s ) { return true; } } return false; } /** * Whether $ips contains at least one address of the same family (IPv4/IPv6) as $ip — * i.e. whether the forward lookup could actually speak to the client's address family. * * @param string[] $ips List of resolved IP strings. * @param string $ip Client IP. * @return bool */ private static function forward_had_family( array $ips, $ip ) { $target = self::ip_family( $ip ); if ( '' === $target ) { return false; } foreach ( $ips as $cand ) { if ( self::ip_family( $cand ) === $target ) { return true; } } return false; } /** * The address family of an IP string: 'v4', 'v6', or '' when not a valid IP. * * @param string $ip IP string. * @return string */ private static function ip_family( $ip ) { $packed = @inet_pton( (string) $ip ); // phpcs:ignore WordPress.PHP.NoSilencedErrors -- invalid input returns false. if ( false === $packed ) { return ''; } if ( 4 === strlen( $packed ) ) { return 'v4'; } return 16 === strlen( $packed ) ? 'v6' : ''; } /** * Whether two IP strings are the same address (normalized). * * @param string $a IP. * @param string $b IP. * @return bool */ private static function same_ip( $a, $b ) { $pa = @inet_pton( (string) $a ); // phpcs:ignore WordPress.PHP.NoSilencedErrors -- invalid input returns false. $pb = @inet_pton( (string) $b ); // phpcs:ignore WordPress.PHP.NoSilencedErrors -- invalid input returns false. return false !== $pa && $pa === $pb; } /** * Reverse DNS (PTR) for an IP → hostname. Filterable (return a string to override, * null to fall through to the native lookup). * * @param string $ip Source IP. * @return string Hostname, or '' when none. */ private static function reverse( $ip ) { /** * Short-circuit the reverse-DNS lookup. Return a hostname string (or '' for * none) to override; null to use the native resolver. * * @param string|null $host Overriding hostname, or null. * @param string $ip Source IP. */ $host = apply_filters( 'agentimus_reverse_dns', null, $ip ); if ( null === $host && function_exists( 'gethostbyaddr' ) ) { $host = @gethostbyaddr( (string) $ip ); // phpcs:ignore WordPress.PHP.NoSilencedErrors -- resolver failure returns false. } return is_string( $host ) ? $host : ''; } /** * Forward DNS (A/AAAA) for a hostname → list of IP strings. Filterable (return an * array to override, null to fall through to the native lookup). * * @param string $host Hostname. * @return string[] */ private static function forward( $host ) { /** * Short-circuit the forward-DNS lookup. Return an array of IP strings to * override; null to use the native resolver. * * @param array|null $ips Overriding IP list, or null. * @param string $host Hostname. */ $ips = apply_filters( 'agentimus_forward_dns', null, $host ); if ( null === $ips ) { $ips = self::native_forward( $host ); } return is_array( $ips ) ? $ips : array(); } /** * Native forward resolution: IPv4 via gethostbynamel plus IPv6 via AAAA records. * * @param string $host Hostname. * @return string[] */ private static function native_forward( $host ) { $out = array(); if ( function_exists( 'gethostbynamel' ) ) { $v4 = @gethostbynamel( (string) $host ); // phpcs:ignore WordPress.PHP.NoSilencedErrors -- resolver failure returns false. IPv4 only. if ( is_array( $v4 ) ) { $out = $v4; } } if ( function_exists( 'dns_get_record' ) ) { $v6 = @dns_get_record( (string) $host, DNS_AAAA ); // phpcs:ignore WordPress.PHP.NoSilencedErrors -- resolver failure returns false. if ( is_array( $v6 ) ) { foreach ( $v6 as $r ) { if ( isset( $r['ipv6'] ) ) { $out[] = (string) $r['ipv6']; } } } } return $out; } /** * Whether the elapsed time since $start exceeds the slow-lookup threshold. * * @param float $start microtime(true) captured before the lookup. * @return bool */ private static function too_slow( $start ) { $ms = ( microtime( true ) - $start ) * 1000; /** * Milliseconds beyond which a single DNS lookup counts as "slow" (a circuit- * breaker strike, and a fail-open null verdict). * * @param int $ms The threshold in milliseconds. */ $limit = (int) apply_filters( 'agentimus_verify_slow_ms', self::SLOW_MS ); return $ms > max( 1, $limit ); } /** * Record one slow/failed-lookup strike; open the circuit breaker once enough * accumulate within the window, so DNS is skipped entirely for a cooldown. * * @return void */ private static function record_strike() { $n = (int) get_transient( self::STRIKE_KEY ) + 1; set_transient( self::STRIKE_KEY, $n, self::STRIKE_TTL ); /** * How many slow-lookup strikes open the verification circuit breaker. * * @param int $strikes Strike count that trips the breaker. */ $trip = (int) apply_filters( 'agentimus_verify_trip_strikes', self::TRIP_STRIKES ); if ( $n >= max( 1, $trip ) ) { set_transient( self::TRIP_KEY, 1, self::TRIP_TTL ); } } /** * Count one new lookup against the per-window budget; false once it is spent. * * @return bool True while the budget allows a new lookup. */ private static function spend_lookup() { $max = (int) apply_filters( 'agentimus_verify_lookup_budget', self::BUDGET_MAX ); $key = self::BUDGET_PREFIX . (int) floor( time() / self::BUDGET_WINDOW ); $n = (int) get_transient( $key ) + 1; set_transient( $key, $n, self::BUDGET_WINDOW * 2 ); return $n <= max( 1, $max ); } }