{"id":358827,"date":"2026-08-26T17:19:49","date_gmt":"2026-08-26T17:19:49","guid":{"rendered":"https:\/\/wordpress.org\/plugins\/botpolice\/"},"modified":"2026-08-26T17:19:31","modified_gmt":"2026-08-26T17:19:31","slug":"psb-bot-traffic-controller","status":"publish","type":"plugin","link":"https:\/\/wordpress.org\/plugins\/psb-bot-traffic-controller\/","author":13958511,"comment_status":"closed","ping_status":"closed","template":"","meta":{"version":"1.0.2","stable_tag":"1.0.2","tested":"7.1","requires":"6.0","requires_php":"8.0","requires_plugins":null,"header_name":"PSB Bot Traffic Controller","header_author":"Paul Stephen Borile","header_description":"Categorized bot detection (AI training bots, AI assistants, autonomous AI agents, search engine crawlers, scraping agents) with per-category block\/limit\/allow policy, rate limiting, robots.txt generation, and a lightweight invisible human-verification check.","assets_banners_color":"d8d9e1","last_updated":"2026-08-26 17:19:31","external_support_url":"","external_repository_url":"","donate_link":"","header_plugin_uri":"https:\/\/www.paulstephenborile.com\/psb-bot-traffic-controller","header_author_uri":"https:\/\/www.paulstephenborile.com\/","rating":0,"author_block_rating":0,"active_installs":0,"downloads":27,"num_ratings":0,"support_threads":0,"support_threads_resolved":0,"author_block_count":0,"sections":["description","installation","faq","changelog"],"tags":{"1.0.2":{"tag":"1.0.2","author":"paulborile","date":"2026-08-26 17:19:31"}},"upgrade_notice":[],"ratings":[],"assets_icons":{"icon-128x128.png":{"filename":"icon-128x128.png","revision":3667516,"resolution":"128x128","location":"assets","locale":"","width":128,"height":128},"icon-256x256.png":{"filename":"icon-256x256.png","revision":3667516,"resolution":"256x256","location":"assets","locale":"","width":256,"height":256}},"assets_banners":{"banner-1544x500.png":{"filename":"banner-1544x500.png","revision":3667516,"resolution":"1544x500","location":"assets","locale":"","width":1544,"height":500},"banner-772x250.png":{"filename":"banner-772x250.png","revision":3667516,"resolution":"772x250","location":"assets","locale":"","width":772,"height":250}},"assets_blueprints":{},"all_blocks":[],"tagged_versions":["1.0.2"],"block_files":[],"assets_screenshots":{"screenshot-1.png":{"filename":"screenshot-1.png","revision":3667516,"resolution":"1","location":"assets","locale":"","width":790,"height":658},"screenshot-2.png":{"filename":"screenshot-2.png","revision":3667516,"resolution":"2","location":"assets","locale":"","width":790,"height":658}},"screenshots":{"1":"Settings page \u2014 configure block\/limit\/allow policy and the per-hour rate threshold for\neach of the 5 bot categories, plus a global safety-net cap applied to all bot traffic.","2":"Dashboard \u2014 mu-plugin install\/health status, persistent object cache detection, bot\npattern-data version, and a rolling per-category block\/limit\/allow activity summary."}},"plugin_section":[],"plugin_tags":[246479,4866,1174,232610,600],"plugin_category":[54],"plugin_contributors":[277694],"plugin_business_model":[],"class_list":["post-358827","plugin","type-plugin","status-publish","hentry","plugin_tags-ai-crawlers","plugin_tags-bots","plugin_tags-firewall","plugin_tags-rate-limiting","plugin_tags-security","plugin_category-security-and-spam-protection","plugin_contributors-paulborile","plugin_committers-paulborile"],"banners":{"banner":"https:\/\/ps.w.org\/psb-bot-traffic-controller\/assets\/banner-772x250.png?rev=3667516","banner_2x":"https:\/\/ps.w.org\/psb-bot-traffic-controller\/assets\/banner-1544x500.png?rev=3667516","banner_rtl":false,"banner_2x_rtl":false},"icons":{"svg":false,"icon":"https:\/\/ps.w.org\/psb-bot-traffic-controller\/assets\/icon-128x128.png?rev=3667516","icon_2x":"https:\/\/ps.w.org\/psb-bot-traffic-controller\/assets\/icon-256x256.png?rev=3667516","generated":false},"screenshots":[{"src":"https:\/\/ps.w.org\/psb-bot-traffic-controller\/assets\/screenshot-1.png?rev=3667516","caption":"Settings page \u2014 configure block\/limit\/allow policy and the per-hour rate threshold for\neach of the 5 bot categories, plus a global safety-net cap applied to all bot traffic."},{"src":"https:\/\/ps.w.org\/psb-bot-traffic-controller\/assets\/screenshot-2.png?rev=3667516","caption":"Dashboard \u2014 mu-plugin install\/health status, persistent object cache detection, bot\npattern-data version, and a rolling per-category block\/limit\/allow activity summary."}],"raw_content":"<!--section=description-->\n<p>PSB Bot Traffic Controller sits in front of your WordPress site and classifies incoming traffic\ninto five bot categories:<\/p>\n\n<ol>\n<li>AI training bots<\/li>\n<li>AI assistants<\/li>\n<li>Autonomous AI agents<\/li>\n<li>Search engine crawlers<\/li>\n<li>Scraping agents<\/li>\n<\/ol>\n\n<p>Each category can be configured independently to block (HTTP 403), rate-limit (HTTP 429 once\na configurable per-hour threshold is exceeded), or allow traffic. A global safety-net cap also\napplies to all detected bot traffic regardless of per-category policy.<\/p>\n\n<p>Detection runs via a free, zero-dependency regex\/User-Agent pattern engine by default, curated\nfrom third-party bot-pattern data (see Credits below). An optional WURFL.js-based detection\nengine can be enabled for higher accuracy.<\/p>\n\n<p>For performance, bot interception runs as early as possible via a small mu-plugin installed\nautomatically into <code>wp-content\/mu-plugins\/<\/code> \u2014 this avoids the cost of a full WordPress\nbootstrap for traffic that ends up being blocked.<\/p>\n\n<h3>Credits<\/h3>\n\n<p>The bundled bot-pattern data (<code>includes\/detection\/regex\/data\/bot-patterns.php<\/code>) is built from\nthree third-party sources, each under a GPL-compatible license:<\/p>\n\n<ul>\n<li><a href=\"https:\/\/github.com\/matomo-org\/device-detector\">matomo-org\/device-detector<\/a> \u2014 LGPL-3.0<\/li>\n<li><a href=\"https:\/\/github.com\/monperrus\/crawler-user-agents\">crawler-user-agents<\/a> \u2014 MIT<\/li>\n<li><a href=\"https:\/\/github.com\/ai-robots-txt\/ai.robots.txt\">ai.robots.txt<\/a> \u2014 MIT<\/li>\n<\/ul>\n\n<p>No code from these projects is bundled \u2014 only pattern\/name\/category data, transformed at build\ntime (<code>tools\/cmd\/build-patterns<\/code>) into this plugin's own runtime format.<\/p>\n\n<p>This plugin makes no outbound HTTP requests and does not integrate with any third-party or\nexternal service. Earlier versions of the bundled pattern data included a per-signature\n    producer field (an attribution URL crediting the upstream source of each entry); it has been\nremoved entirely, since it was never fetched or otherwise dereferenced by the plugin at\nruntime.<\/p>\n\n<!--section=installation-->\n<ol>\n<li>Upload the plugin zip via Plugins &gt; Add New &gt; Upload Plugin, or extract it into\n   wp-content\/plugins\/psb-bot-traffic-controller\/.<\/li>\n<li>Activate the plugin. This installs a small companion file into <code>wp-content\/mu-plugins\/<\/code>\n(WordPress's \"must-use plugin\" mechanism) that performs early bot interception.<\/li>\n<li>Configure per-category policy under Settings &gt; PSB Bot Traffic Controller.<\/li>\n<\/ol>\n\n<!--section=faq-->\n<dl>\n<dt id=\"does%20this%20replace%20a%20waf%20or%20cdn-level%20bot%20protection%3F\"><h3>Does this replace a WAF or CDN-level bot protection?<\/h3><\/dt>\n<dd><p>No \u2014 PSB Bot Traffic Controller runs inside WordPress (or, for the mu-plugin path, as early in\nWordPress's own bootstrap as possible) and cannot intercept traffic before it reaches your web\nserver. It complements, rather than replaces, edge-level protection.<\/p><\/dd>\n<dt id=\"what%20happens%20if%20the%20mu-plugin%20file%20can%27t%20be%20written%20on%20activation%3F\"><h3>What happens if the mu-plugin file can't be written on activation?<\/h3><\/dt>\n<dd><p>An admin notice is shown with the filesystem error. The plugin still activates, but early\ninterception won't run until the mu-plugin is successfully installed (retry by\ndeactivating\/reactivating, or check file permissions on <code>wp-content\/mu-plugins\/<\/code>).<\/p><\/dd>\n<dt id=\"how%20do%20i%20reset%20rate-limit%20counters%20while%20testing%3F\"><h3>How do I reset rate-limit counters while testing?<\/h3><\/dt>\n<dd><p>Add <code>define( 'PSB_BTC_DEBUG', true );<\/code> to <code>wp-config.php<\/code> on a staging\/test site. This\nenables a rotating-token reset URL shown on the Dashboard (valid for a few hours at a time,\nthen automatically expires). It is disabled by default and should never be enabled on a\nproduction site.<\/p><\/dd>\n\n<\/dl>\n\n<!--section=changelog-->\n<h4>1.0.2<\/h4>\n\n<ul>\n<li>Re-analyzed every remaining automated Plugin Check finding against this version's actual\ncode, rather than restating prior documentation, and applied two genuine fixes found in the\nprocess (both comment-only, zero behavior change):<\/li>\n<li>Added <code>phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared<\/code> annotations to the\nthree interpolated-table-name queries in <code>class-ratelimit-store-db.php<\/code>\n(<code>increment_and_get()<\/code>, <code>get_count()<\/code>, <code>cleanup_expired()<\/code>) \u2014 this file had none, unlike its\nsibling <code>class-ratelimit-reset.php<\/code>, despite sharing the identical unfixable-by-design\npattern (table names can never be <code>%s<\/code>\/<code>%d<\/code> <code>$wpdb-&gt;prepare()<\/code> placeholders).<\/li>\n<li>Corrected <code>class-ratelimit-reset.php<\/code>'s existing <code>phpcs:ignore<\/code> comment on its\n  TRUNCATE TABLE query: it previously named the sniff <code>WordPress.DB.PreparedSQL.NotPrepared<\/code>,\nwhich is not what actually fires there \u2014 fixed to name\n  WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter,\nmatching what the checker actually reports, so the comment provides real suppression instead\nof none.<\/li>\n<\/ul>\n\n<h4>1.0.1<\/h4>\n\n<ul>\n<li>Regenerated the icon\/banner\/logo image assets from an updated source design (ring emblem +\n\"BOT TRAFFIC CONTROLLER\" wordmark), replacing the placeholder artwork shipped in 1.0.0.<\/li>\n<li>Sanitize <code>psb_btc_mu_respond()<\/code>'s output (strip control characters before echoing) rather\nthan relying on every current call site happening to pass a hardcoded literal \u2014 all 3 call\nsites still do today, so this closes a latent gap rather than fixing a live exploit, but\nescaping at the point of output shouldn't depend on that staying true as the file is edited.<\/li>\n<li>Re-reviewed the remaining automated Plugin Check findings against this version and confirmed\nthey remain the already-documented false positives \/ accepted architectural exceptions\ncovered in <code>SUBMISSION.md<\/code> (non-atomic-counter <code>flock()<\/code> locking has no <code>WP_Filesystem<\/code>\nequivalent and must run pre-bootstrap; <code>$wpdb-&gt;prepare()<\/code> placeholders the checker's static\nanalysis can't trace through) \u2014 nothing else new to fix.<\/li>\n<\/ul>\n\n<h4>1.0.0<\/h4>\n\n<ul>\n<li>Renamed the plugin from \"botpolice\" to \"PSB Bot Traffic Controller\" (slug\n  psb-bot-traffic-controller), following a WordPress.org plugin review finding that\n\"botpolice\" too closely matched an existing project with overlapping functionality.\nEvery internal class\/function\/constant prefix, option\/table\/transient name, and\ngenerated mu-plugin filename was renamed to match (<code>Botpolice_*<\/code> \u2192 <code>PSB_BTC_*<\/code>,\n  botpolice_* \u2192 <code>psb_btc_*<\/code>, <code>BOTPOLICE_*<\/code> \u2192 <code>PSB_BTC_*<\/code>).<\/li>\n<li>Removed the <code>producer<\/code> attribution field from the bundled bot-pattern data\n(<code>includes\/detection\/regex\/data\/bot-patterns.php<\/code>) \u2014 it held a URL in most entries and\nwas flagged twice by review under two headings (\"calling files remotely\" and\n\"undocumented use of a 3rd party\/external service\"), even though it was never\ndereferenced as a URL anywhere in the runtime code. Removed entirely rather than\ndisclosed, since the plugin makes no outbound HTTP requests and integrates with no\nexternal service.<\/li>\n<li>Changed the mu-plugin's per-category config cache from an executable\n  &lt;?php return array(...); file to plain JSON (now <code>psb-btc-config-cache.json<\/code>,\nread via <code>json_decode()<\/code>), per a review finding against writing executable code\nfiles into <code>wp-content\/mu-plugins\/<\/code> outside the small set of core-defined drop-in\nexceptions. The mu-plugin bootstrap file itself and the (much larger) pattern-data\ncache were not affected \u2014 review did not flag either of those.<\/li>\n<li>Fixed a real correctness bug review's automated tooling also caught: both the\nobject-cache-backed rate-limit store and the mu-plugin's flat-file rate-limit\ncounters used a non-atomic read-increment-write sequence, letting concurrent\nrequests undercount and bypass configured limits. The object-cache store now uses\n  wp_cache_add()\/<code>wp_cache_incr()<\/code> (atomic at the cache backend); the mu-plugin's\nflat-file counters now use <code>flock()<\/code>-based exclusive locking around the\nread-modify-write.<\/li>\n<li>Replaced the compound <code>if ( ! defined('ABSPATH') &amp;&amp; ! defined('...STANDALONE') )<\/code>\ndirect-access guard (used to allow PHPUnit to load classes without a full WP\ninstall) with the literal bare <code>if ( ! defined( 'ABSPATH' ) ) { exit; }<\/code> review\nexpects, across all affected files. Test-only standalone loading now works by\nhaving the PHPUnit bootstrap itself define <code>ABSPATH<\/code>, rather than each production\nfile special-casing a second constant.<\/li>\n<\/ul>\n\n<h4>0.4.1<\/h4>\n\n<ul>\n<li>Address findings from the official WordPress Plugin Check tool: escape remaining\nunescaped Dashboard output, switch <code>class-mu-installer.php<\/code>'s version-stamp read and\n  class-ratelimit-reset.php's counter-file cleanup off raw file-handle\/<code>unlink()<\/code> calls,\ndocument the intentional nonce-free design of the (opt-in, off-by-default) debug reset\nendpoint, add a direct-access guard to the generated <code>bot-patterns.php<\/code> (via\n  tools\/cmd\/build-patterns, not by hand-editing the output), and correct \"Tested up to\"\nto the current WordPress release. See <code>SUBMISSION.md<\/code> for a list of remaining Plugin\nCheck findings that are intentional design choices rather than defects (the\n  BOTPOLICE_STANDALONE direct-access guard variant, the mu-plugin's necessarily-early\nfilesystem calls, and the reset endpoint's token-based-not-nonce-based auth).<\/li>\n<\/ul>\n\n<h4>0.4.0<\/h4>\n\n<ul>\n<li>The rate-limit-reset debug endpoint (<code>?botpolice_reset=&lt;token&gt;<\/code>) is no longer loaded or\nregistered by default \u2014 it now requires <code>define( 'BOTPOLICE_DEBUG', true );<\/code> in\n  wp-config.php to become reachable at all, rather than being an unauthenticated endpoint\npresent on every install. <code>uninstall.php<\/code> also now removes its secret option, if present.<\/li>\n<li>Prepare for WordPress.org Plugin Directory submission: tightened the short description\nto fit the directory's display limit, corrected the \"Tested up to\" header (was a\nnonexistent \"7.0\"), added a Screenshots section covering the Settings and Dashboard\nadmin pages, added a Credits section disclosing and licensing the bundled bot-pattern\ndata's third-party sources, and fixed composer.json declaring \"proprietary\" while the\nplugin itself is GPLv2 or later.<\/li>\n<\/ul>\n\n<h4>0.3.2<\/h4>\n\n<ul>\n<li>Fix: the debug rate-limit-reset token (<code>?botpolice_reset=&lt;token&gt;<\/code>, shown on the\nDashboard for manual testing) was a single fixed value stored in <code>wp_options<\/code> \u2014\nonce leaked (screenshot, shared log, browser history), it stayed valid forever.\nIt now rotates automatically every 4 hours: the displayed\/accepted token is an\nHMAC-SHA256 of a persistent random secret (never displayed) and the current\n4-hour time window, so a leaked token expires on its own without any manual\nrotation step, and reading the plugin's source alone is not enough to compute a\nvalid token (the algorithm is public; the secret it's keyed on is not). The\nDashboard now also shows how many minutes remain before the current URL expires.<\/li>\n<\/ul>\n\n<h4>0.3.1<\/h4>\n\n<ul>\n<li>Add the product logo\/emblem to the Settings and Dashboard admin page headers\n(<code>assets\/images\/logo.png<\/code>, shipped in the plugin zip) and WordPress.org-listing icon\nand banner assets (<code>.wordpress-org\/<\/code>, not bundled into the plugin zip per WP.org\nconvention \u2014 those live in the SVN assets\/ directory of the plugin listing, separate\nfrom trunk\/).<\/li>\n<\/ul>\n\n<h4>0.3.0<\/h4>\n\n<ul>\n<li>Fix: rate-limiting and category policy keyed on the raw TCP peer address\n(<code>REMOTE_ADDR<\/code>), which is the reverse proxy's\/load balancer's own IP on any site\nrunning behind one (Cloudflare, nginx, AWS ALB\/ELB) \u2014 collapsing every distinct\nvisitor onto one shared rate-limit bucket. Added <code>Botpolice_Client_Ip<\/code>\n(<code>includes\/detection\/class-client-ip.php<\/code>, documented in <code>CLIENT_IP.md<\/code>), which\nresolves the real public client IP by walking the <code>Forwarded<\/code> (RFC 7239) and\n  X-Forwarded-For headers before falling back to the raw peer address, and only\never returns an internet-routable address for rate-limiting purposes. Wired into\nboth enforcement layers: <code>Botpolice_Request_Context<\/code> (full-WP path) and a\nduplicated <code>botpolice_mu_client_ip()<\/code> in the self-contained mu-plugin hot path.<\/li>\n<\/ul>\n\n<h4>0.2.1<\/h4>\n\n<ul>\n<li>Fix: mu-plugin bootstrap and its pattern-data cache could go stale after an upload-based\nplugin upgrade (WordPress does not re-fire the activation hook on that path), silently\nrunning old enforcement logic indefinitely. Added <code>Botpolice_Upgrade_Handler<\/code>, which resyncs\nthe mu-plugin on <code>upgrader_process_complete<\/code> and via a cheap staleness check on every\nadmin page load, covering upgrade paths that bypass WordPress's upgrader (e.g. manual FTP\nreplacement) as well.<\/li>\n<\/ul>\n\n<h4>0.2.0<\/h4>\n\n<ul>\n<li>Add per-category, per-action activity recording (<code>Botpolice_Activity_Recorder<\/code> and the\nmu-plugin's own inline counter), surfaced on the dashboard's Recent Activity view.<\/li>\n<li>Add a token-gated debug endpoint (<code>?botpolice_reset=&lt;token&gt;<\/code>) to clear rate-limit counters\nfor iterative live-site testing without waiting out hourly buckets.<\/li>\n<li>Add a curl-based live end-to-end test suite (<code>tests\/live\/test-live-site.sh<\/code>,\n  make test-live) verifying block\/limit\/allow enforcement against a real deployed site.<\/li>\n<\/ul>\n\n<h4>0.1.1<\/h4>\n\n<ul>\n<li>Fix: mu-plugin helper functions were declared conditionally (inside <code>function_exists()<\/code>\nguards) but called from a closure defined before those declarations executed \u2014 PHP only\nhoists unconditional top-level function declarations, so any request matching a bot pattern\nhit a fatal \"call to undefined function\" error, blocking all traffic on the plugin's\nhighest-risk hot path. Passed a plain <code>php -l<\/code> lint check without complaint. Caught by a new\nsubprocess-based regression suite (<code>tests\/php\/mu-bootstrap\/<\/code>), since PHPUnit cannot safely\nintercept <code>exit()<\/code>-calling code in-process.<\/li>\n<\/ul>\n\n<h4>0.1.0<\/h4>\n\n<ul>\n<li>Initial development release: regex\/User-Agent detection engine (911 pattern entries across\n5 categories, built from matomo-org\/device-detector, crawler-user-agents, and ai.robots.txt),\npolicy engine (block\/limit\/allow per category plus a global rate cap), rate limiter\n(transients or DB-table-backed), mu-plugin-based early interception with a\nWP-API-backed fallback layer, Settings\/Dashboard admin UI, and installable-zip packaging.<\/li>\n<\/ul>","raw_excerpt":"Block, limit, or allow AI crawlers, search bots, and scrapers by category \u2014 with rate limiting and robots.txt generation.","jetpack_sharing_enabled":true,"_links":{"self":[{"href":"https:\/\/wordpress.org\/plugins\/wp-json\/wp\/v2\/plugin\/358827","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/wordpress.org\/plugins\/wp-json\/wp\/v2\/plugin"}],"about":[{"href":"https:\/\/wordpress.org\/plugins\/wp-json\/wp\/v2\/types\/plugin"}],"replies":[{"embeddable":true,"href":"https:\/\/wordpress.org\/plugins\/wp-json\/wp\/v2\/comments?post=358827"}],"author":[{"embeddable":true,"href":"https:\/\/wordpress.org\/plugins\/wp-json\/wporg\/v1\/users\/paulborile"}],"wp:attachment":[{"href":"https:\/\/wordpress.org\/plugins\/wp-json\/wp\/v2\/media?parent=358827"}],"wp:term":[{"taxonomy":"plugin_section","embeddable":true,"href":"https:\/\/wordpress.org\/plugins\/wp-json\/wp\/v2\/plugin_section?post=358827"},{"taxonomy":"plugin_tags","embeddable":true,"href":"https:\/\/wordpress.org\/plugins\/wp-json\/wp\/v2\/plugin_tags?post=358827"},{"taxonomy":"plugin_category","embeddable":true,"href":"https:\/\/wordpress.org\/plugins\/wp-json\/wp\/v2\/plugin_category?post=358827"},{"taxonomy":"plugin_contributors","embeddable":true,"href":"https:\/\/wordpress.org\/plugins\/wp-json\/wp\/v2\/plugin_contributors?post=358827"},{"taxonomy":"plugin_business_model","embeddable":true,"href":"https:\/\/wordpress.org\/plugins\/wp-json\/wp\/v2\/plugin_business_model?post=358827"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}