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:1895 · severity blocker · lens gate-completeness
The defect
check_login() returns a WP_Error on the authenticate filter with no suppression_reason() and no WEBDECOY_DISABLE check, so the documented emergency off switch cannot recover a login lockout. WEBDECOY_DISABLE is honoured only inside early_check() (:1024); the authenticate hook registered at :825 never sees it.
Failure scenario
protect_login=true and sensitivity='high' (multiplier 1.3). Owner authenticates from a client the detector scores at or above 75, e.g. a python-requests-based XML-RPC or REST tool: 3 missing Accept headers give 30, cli_tool gives 35, total 65 x 1.3 = 84. check_login() returns WP_Error('webdecoy_blocked') and the login is refused. Owner turns monitor mode on, or follows the settings-page and code-comment instruction (webdecoy.php:1021-1023, settings-page.php:498) and adds define('WEBDECOY_DISABLE', true) to wp-config.php over FTP. Neither restores login, because the constant is only checked in early_check(). The only remaining recovery is direct database or filesystem surgery, which is exactly what the switch was added to avoid.
Verified mechanism
The mechanism is exactly as claimed and survives every refutation attempt.
VERIFIED CHAIN:
- webdecoy.php:824-825 registers add_filter('authenticate', [$this,'check_login'], 30, 3) gated only on options['protect_login'], which defaults to TRUE (webdecoy.php:286). So this path is live on every install.
- webdecoy.php:1883-1903 check_login(): after the is_wp_error() early return at :1886, it runs check_honeypot('login'), analyze(), then at :1895 shouldBlock() and at :1896 returns new WP_Error('webdecoy_blocked'). There is no suppression_reason(), no WEBDECOY_DISABLE, no options['enabled'], no block_action, and no is_allowlisted() check anywhere in the function.
- WEBDECOY_DISABLE is read in exactly two places: early_check() at :1024 and suppression_reason() at :389. Neither is reachable from the authenticate filter. grep for enforcement_suppressed|suppression_reason|remove_filter across webdecoy.php, includes/ and admin/ returns only :1048 (early_check 403), :1420 (enforce_block), :1468 (handle_rule_decision) and class-webdecoy-woocommerce.php:455. Nothing gates the authenticate hook.
- The plugin is instantiated unconditionally at webdecoy.php:3253 (webdecoy()), and init_hooks() runs from the constructor at :181, so the constant cannot prevent hook registration either.
- Because check_login sits at priority 30 and wp_authenticate_username_password runs at 20, the only requests that reach :1895 are ones where the credentials were CORRECT. So the failure mode is precisely "right password, login refused".
ARITHMETIC CORRECTION (does not change the mechanism or the fix location):
The claim's specific client is wrong. python-requests sends Accept: / and Accept-Encoding: gzip, deflate by default, so only HTTP_ACCEPT_LANGUAGE is missing out of EXPECTED_HEADERS (sdk/src/SignalCollector.php:16-20) => 10 raw + 35 cli_tool = 45 raw => 13+45 = 58 at high, below the 75 threshold. The stated 3-missing-header total IS exactly right for libwww-perl, which is in the cliTools list (sdk/src/BotDetector.php:384) and whose LWP::UserAgent sends only Host and User-Agent by default: 301.3 = 39 plus (int)(351.3) = 45 => 84 >= 75. So 84 is reachable, from a different client than named. At the DEFAULT medium sensitivity the same client scores 65, below threshold, so the login path specifically needs non-default sensitivity='high' or one extra signal (e.g. a UA containing "bot" not on the good-bot list gives SCORE_BOT_UA 50 + 30 headers = 80 at default medium).
WHY IT BLOCKS THE RELEASE (the promise, not the arithmetic):
The same missing gate exists at three sibling sites, and one of them is reachable at DEFAULT settings and contradicts the release copy verbatim:
- webdecoy.php:1813-1818 filter_comment() calls wp_die(..., ['response' => 403]) with no gate. admin/partials/settings-page.php:490 states monitor mode means "no visitor is ever blocked, throttled or shown a 403". A comment POST from a generic-"bot" UA scores 80 at default settings, so a stock 2.3.2 install in its default monitor mode serves a literal 403 while the admin notice from render_state_notices() says "Everything is detected and logged; nothing is blocked."
- webdecoy.php:1919 check_registration() adds a WP_Error, ungated.
- webdecoy.php:1865 check_canary_login() returns WP_Error, ungated (its enforce_block at :1863 WAS gated in this diff, its refusal was not).
So readme.txt:201 ("blocks nothing until you switch blocking on"), readme.txt:210 / settings-page.php:497 ("stop the plugin acting on the front end entirely"), and the monitor-mode admin notice are all false as shipped. By the stated standard - "a blocker is something that breaks a real site or makes the changelog/admin-notice promise false" - this qualifies on the second count directly and on the first for the login path.
The login case is the one that must be unconditional regardless of how hard the score is to hit: the whole documented purpose of WEBDECOY_DISABLE (webdecoy.php:1021-1023) is FTP-only recovery from a lockout, and a check_login lockout is the only lockout the constant cannot clear, leaving DB surgery as the sole recovery. An emergency switch with an exception is not an emergency switch.
Not pre-existing-and-therefore-excusable: check_login itself is unchanged by the diff, but WEBDECOY_DISABLE, monitor_mode and the "blocks nothing" claims are all NEW in 2.3.2. The defect is that the new gate was wired into four block sites and the WooCommerce path (class-webdecoy-woocommerce.php:455 proves the author knew the pattern) but missed the four form-protection refusal sites.
Fix
Gate the refusal, not the detection, so monitor mode still records the counterfactual.
webdecoy.php:1895 — replace the body of the if with a suppression check:
if ($result->shouldBlock($this->options['min_score_to_block'])) {
$suppression = $this->suppression_reason();
if ($suppression !== '') {
$this->record_suppressed_action($suppression, 'Login refused: score ' . $result->getScore());
return $user;
}
return new \WP_Error(
'webdecoy_blocked',
__('Login blocked due to suspicious activity.', 'webdecoy')
);
}
(record_suppressed_action() at :1442 is private on the same class, so this is callable as-is.)
Apply the identical two-line gate at the three sibling sites that have the same gap, or the "blocks nothing / never a 403" promise stays false:
- webdecoy.php:1813 filter_comment() — return $commentdata instead of wp_die() when suppression_reason() !== ''. This one is reachable at DEFAULT sensitivity and serves a literal 403, so it must be in the same commit.
- webdecoy.php:1919 check_registration() — skip $errors->add() when suppressed.
- webdecoy.php:1865 check_canary_login() — return $user instead of WP_Error. If a canary hit is considered zero-false-positive and worth refusing in monitor mode, then at minimum honour the 'disabled' reason there (if (defined('WEBDECOY_DISABLE') && WEBDECOY_DISABLE) { return $user; }), so the kill switch is genuinely unconditional.
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:1895 · severity
blocker· lensgate-completenessThe defect
check_login() returns a WP_Error on the authenticate filter with no suppression_reason() and no WEBDECOY_DISABLE check, so the documented emergency off switch cannot recover a login lockout. WEBDECOY_DISABLE is honoured only inside early_check() (:1024); the authenticate hook registered at :825 never sees it.
Failure scenario
protect_login=true and sensitivity='high' (multiplier 1.3). Owner authenticates from a client the detector scores at or above 75, e.g. a python-requests-based XML-RPC or REST tool: 3 missing Accept headers give 30, cli_tool gives 35, total 65 x 1.3 = 84. check_login() returns WP_Error('webdecoy_blocked') and the login is refused. Owner turns monitor mode on, or follows the settings-page and code-comment instruction (webdecoy.php:1021-1023, settings-page.php:498) and adds define('WEBDECOY_DISABLE', true) to wp-config.php over FTP. Neither restores login, because the constant is only checked in early_check(). The only remaining recovery is direct database or filesystem surgery, which is exactly what the switch was added to avoid.
Verified mechanism
The mechanism is exactly as claimed and survives every refutation attempt.
VERIFIED CHAIN:
ARITHMETIC CORRECTION (does not change the mechanism or the fix location):
The claim's specific client is wrong. python-requests sends Accept: / and Accept-Encoding: gzip, deflate by default, so only HTTP_ACCEPT_LANGUAGE is missing out of EXPECTED_HEADERS (sdk/src/SignalCollector.php:16-20) => 10 raw + 35 cli_tool = 45 raw => 13+45 = 58 at high, below the 75 threshold. The stated 3-missing-header total IS exactly right for libwww-perl, which is in the cliTools list (sdk/src/BotDetector.php:384) and whose LWP::UserAgent sends only Host and User-Agent by default: 301.3 = 39 plus (int)(351.3) = 45 => 84 >= 75. So 84 is reachable, from a different client than named. At the DEFAULT medium sensitivity the same client scores 65, below threshold, so the login path specifically needs non-default sensitivity='high' or one extra signal (e.g. a UA containing "bot" not on the good-bot list gives SCORE_BOT_UA 50 + 30 headers = 80 at default medium).
WHY IT BLOCKS THE RELEASE (the promise, not the arithmetic):
The same missing gate exists at three sibling sites, and one of them is reachable at DEFAULT settings and contradicts the release copy verbatim:
So readme.txt:201 ("blocks nothing until you switch blocking on"), readme.txt:210 / settings-page.php:497 ("stop the plugin acting on the front end entirely"), and the monitor-mode admin notice are all false as shipped. By the stated standard - "a blocker is something that breaks a real site or makes the changelog/admin-notice promise false" - this qualifies on the second count directly and on the first for the login path.
The login case is the one that must be unconditional regardless of how hard the score is to hit: the whole documented purpose of WEBDECOY_DISABLE (webdecoy.php:1021-1023) is FTP-only recovery from a lockout, and a check_login lockout is the only lockout the constant cannot clear, leaving DB surgery as the sole recovery. An emergency switch with an exception is not an emergency switch.
Not pre-existing-and-therefore-excusable: check_login itself is unchanged by the diff, but WEBDECOY_DISABLE, monitor_mode and the "blocks nothing" claims are all NEW in 2.3.2. The defect is that the new gate was wired into four block sites and the WooCommerce path (class-webdecoy-woocommerce.php:455 proves the author knew the pattern) but missed the four form-protection refusal sites.
Fix
Gate the refusal, not the detection, so monitor mode still records the counterfactual.
webdecoy.php:1895 — replace the body of the if with a suppression check:
(record_suppressed_action() at :1442 is private on the same class, so this is callable as-is.)
Apply the identical two-line gate at the three sibling sites that have the same gap, or the "blocks nothing / never a 403" promise stays false:
Blocks the 2.3.2 release. Refs #54.