Found by the adversarial review of PR #54 (2.3.2 safety release). Verified — the reviewer
attempted to refute this and could not.
webdecoy.php:1652 · severity blocker · lens gate-completeness
The defect
handle_blocking()'s challenge branch calls serve_challenge_page(), which sends status_header(403) plus a JS proof-of-work page and exit(), with no suppression_reason() check. Only the sibling block branch at :1657 is gated, via enforce_block().
Failure scenario
Owner selects Block Action = 'challenge' (offered at admin/partials/settings-page.php:524) and leaves monitor_mode=true (default). Site is behind Cloudflare with behind_cloudflare=false (default), so SignalCollector::getIP() returns the CF edge address for every request. Googlebot crawls: BotDetector::analyze() identifies the good-bot UA, GoodBotList::verifyBotIP() reverse-resolves 172.68.x.x which never maps to googlebot.com, so sdk/src/BotDetector.php:222 forces score = SCORE_FAKE_BOT = 80, above min_score_to_block = 75. early_check() (:1112) calls handle_blocking(), which serves a 403 challenge page and exits. Every crawler and every JS-less client gets 403'd and the site deindexes, while the admin notice states nothing is blocked, throttled or shown a 403.
Verified mechanism
CONFIRMED exactly as claimed, mechanism and location both correct.
handle_blocking() (webdecoy.php:1637) has two enforcement branches. The block branch at :1657 goes through enforce_block(), which consults suppression_reason(). The challenge branch at :1645-1653 does not — it calls serve_challenge_page() at :1652, which does status_header(403) at :1687 and exit(). git diff main...HEAD -- webdecoy.php shows the challenge path was never touched by this release: it is the one enforcement site the new gate missed. All four routed sites plus is_blocked (:1048) and handle_rule_decision (:1468) are gated; :1652 is the sole ungated 403 in the plugin.
Chain verified link by link:
- monitor_mode defaults true (:231), so every upgraded install is suppressed.
- block_action 'challenge' is a real, selectable, sanitizer-accepted value (settings-page.php:524; sanitizer :381).
- behind_cloudflare defaults false (:212) and get_trusted_proxies() (:344) returns [] without it, so getIP() correctly returns the CF edge address for every request AND proxy_misconfigured() (:376) is simultaneously true.
- verify_bot_ips defaults true (BotDetector.php:159) and class-webdecoy-detector.php:49-54 never overrides it, so the fake-bot path is always live. googlebot is in VERIFIABLE_BOTS (GoodBotList.php:46) and performReverseDNSVerification() fails closed on a CF address (hostname_mismatch, :711).
- BotDetector.php:222 forces score = SCORE_FAKE_BOT = 80 (:30) with setIsGoodBot(false), so isGoodBot() short-circuits do not fire and shouldBlock(75) (DetectionResult.php:100) is true against the default min_score_to_block => 75 (:218).
- early_check() reaches handle_blocking() at :1115 (claim said :1112 — off by three, immaterial).
Both suppression states are bypassed, not just monitor: with monitor_mode on OR a misconfigured proxy, the 403 challenge is served. Only 'disabled' is safe, because WEBDECOY_DISABLE returns at the top of early_check (:1024).
Blocker, on the stated criterion that a false admin-notice/changelog promise is one. Two strings are made literally false, not merely optimistic: settings-page.php:491 says "no visitor is ever blocked, throttled or shown a 403", and the proxy notice at webdecoy.php:488 says "Only blocking, rate limiting and the 403 page are withheld." Worse, because the branch never reaches record_suppressed_action(), the monitor-mode notice at :511 prints "No request has met the bar for enforcement yet" while a 403 plus templates/challenge-page.php:75's noindex,nofollow goes to every crawler and every JS-less client. That is the exact panic path this release exists to serve: an owner hit by "you blocked my customers" turns monitor mode on, is told nothing is blocked, and the blocking continues.
Honest counterweight, which does not change the verdict: block_action defaults to 'block', so fresh installs and installs that never changed the setting are unaffected, and for installs that did pick 'challenge' the 403 behavior is unchanged from 2.3.0 rather than newly introduced. So this is a hole in the new safety net rather than a new regression — but the safety net is the whole release, the promise is asserted in two places in the UI, no test in tests/ covers the challenge path at all, and the fix is one guard block.
Fix
webdecoy.php:1645-1653 — gate the challenge branch on the same suppression_reason() the block branch uses, placing the check AFTER is_challenge_verified() so a visitor who already solved the challenge is not counted as a withheld action:
if ($action === 'challenge') {
// Check if already verified via cookie
if ($this->is_challenge_verified($ip)) {
return;
}
// The challenge page is a 403. It is enforcement, so it passes the
// same gate as a block.
$suppression = $this->suppression_reason();
if ($suppression !== '') {
do_action('webdecoy_enforcement_suppressed', $ip, $suppression, null);
$this->record_suppressed_action(
$suppression,
'Challenge (score: ' . $result->getScore() . ')'
);
return;
}
// Serve challenge page
$this->serve_challenge_page($ip);
return;
}
Three properties this gets right: the 403 is withheld in both 'monitor' and 'proxy' states (matching what settings-page.php:491 and webdecoy.php:488 already claim); the webdecoy_enforcement_suppressed hook fires with the same (ip, suppression, null) signature enforce_block() (:1421) already uses; and record_suppressed_action() makes the challenge decision show up in the monitor-mode counter at :511, so the notice stops saying "No request has met the bar for enforcement yet" while 403-ing everyone. Not high-volume like THROTTLE, so the option write is safe here.
Note that suppression_reason()'s 'disabled' arm is unreachable from this path — early_check() already returns on WEBDECOY_DISABLE at :1024 — but calling the shared gate rather than checking monitor_mode/proxy_misconfigured() individually keeps the single-gate invariant the release is built on.
Blocks the 2.3.2 release. Refs #54.
Found by the adversarial review of PR #54 (2.3.2 safety release). Verified — the reviewer
attempted to refute this and could not.
webdecoy.php:1652 · severity
blocker· lensgate-completenessThe defect
handle_blocking()'s challenge branch calls serve_challenge_page(), which sends status_header(403) plus a JS proof-of-work page and exit(), with no suppression_reason() check. Only the sibling block branch at :1657 is gated, via enforce_block().
Failure scenario
Owner selects Block Action = 'challenge' (offered at admin/partials/settings-page.php:524) and leaves monitor_mode=true (default). Site is behind Cloudflare with behind_cloudflare=false (default), so SignalCollector::getIP() returns the CF edge address for every request. Googlebot crawls: BotDetector::analyze() identifies the good-bot UA, GoodBotList::verifyBotIP() reverse-resolves 172.68.x.x which never maps to googlebot.com, so sdk/src/BotDetector.php:222 forces score = SCORE_FAKE_BOT = 80, above min_score_to_block = 75. early_check() (:1112) calls handle_blocking(), which serves a 403 challenge page and exits. Every crawler and every JS-less client gets 403'd and the site deindexes, while the admin notice states nothing is blocked, throttled or shown a 403.
Verified mechanism
CONFIRMED exactly as claimed, mechanism and location both correct.
handle_blocking() (webdecoy.php:1637) has two enforcement branches. The block branch at :1657 goes through enforce_block(), which consults suppression_reason(). The challenge branch at :1645-1653 does not — it calls serve_challenge_page() at :1652, which does status_header(403) at :1687 and exit().
git diff main...HEAD -- webdecoy.phpshows the challenge path was never touched by this release: it is the one enforcement site the new gate missed. All four routed sites plus is_blocked (:1048) and handle_rule_decision (:1468) are gated; :1652 is the sole ungated 403 in the plugin.Chain verified link by link:
Both suppression states are bypassed, not just monitor: with monitor_mode on OR a misconfigured proxy, the 403 challenge is served. Only 'disabled' is safe, because WEBDECOY_DISABLE returns at the top of early_check (:1024).
Blocker, on the stated criterion that a false admin-notice/changelog promise is one. Two strings are made literally false, not merely optimistic: settings-page.php:491 says "no visitor is ever blocked, throttled or shown a 403", and the proxy notice at webdecoy.php:488 says "Only blocking, rate limiting and the 403 page are withheld." Worse, because the branch never reaches record_suppressed_action(), the monitor-mode notice at :511 prints "No request has met the bar for enforcement yet" while a 403 plus templates/challenge-page.php:75's noindex,nofollow goes to every crawler and every JS-less client. That is the exact panic path this release exists to serve: an owner hit by "you blocked my customers" turns monitor mode on, is told nothing is blocked, and the blocking continues.
Honest counterweight, which does not change the verdict: block_action defaults to 'block', so fresh installs and installs that never changed the setting are unaffected, and for installs that did pick 'challenge' the 403 behavior is unchanged from 2.3.0 rather than newly introduced. So this is a hole in the new safety net rather than a new regression — but the safety net is the whole release, the promise is asserted in two places in the UI, no test in tests/ covers the challenge path at all, and the fix is one guard block.
Fix
webdecoy.php:1645-1653 — gate the challenge branch on the same suppression_reason() the block branch uses, placing the check AFTER is_challenge_verified() so a visitor who already solved the challenge is not counted as a withheld action:
Three properties this gets right: the 403 is withheld in both 'monitor' and 'proxy' states (matching what settings-page.php:491 and webdecoy.php:488 already claim); the webdecoy_enforcement_suppressed hook fires with the same (ip, suppression, null) signature enforce_block() (:1421) already uses; and record_suppressed_action() makes the challenge decision show up in the monitor-mode counter at :511, so the notice stops saying "No request has met the bar for enforcement yet" while 403-ing everyone. Not high-volume like THROTTLE, so the option write is safe here.
Note that suppression_reason()'s 'disabled' arm is unreachable from this path — early_check() already returns on WEBDECOY_DISABLE at :1024 — but calling the shared gate rather than checking monitor_mode/proxy_misconfigured() individually keeps the single-gate invariant the release is built on.
Blocks the 2.3.2 release. Refs #54.