Found by the adversarial review of PR #54 (2.3.2 safety release). Verified — the reviewer
attempted to refute this and could not.
admin/partials/settings-page.php:487 · severity blocker · lens upgrade-and-defaults
The defect
The Monitor mode checkbox reads the RAW stored option array ($options = get_option('webdecoy_options', []) at line 13), which never contains the new monitor_mode key after an upgrade — so it renders UNCHECKED on every existing install while the plugin is actually in monitor mode. Because an unchecked box submits nothing and sanitize_options() (webdecoy.php:2141) rebuilds the array from scratch with $sanitized['monitor_mode'] = !empty($input['monitor_mode']), the very next save of the settings form persists false and turns full enforcement on. The release's central safety promise survives exactly until the owner saves settings once, for any reason.
Failure scenario
Site on 2.3.0 upgrades to 2.3.2. load_options() merges monitor_mode => true, so the plugin is suppressed and render_state_notices() prints either "WebDecoy is in monitor mode" or the proxy-misconfigured notice whose primary button is "Configure trusted proxies". The owner clicks through to Settings → Blocking, where the Monitor mode box is displayed unchecked (contradicting the notice they just read). They paste 173.245.48.0/20 into Trusted proxies and click Save Changes. POST contains no webdecoy_options[monitor_mode], so false is stored. In one save the site goes from fully suppressed to fully enforcing — blocking, throttling and 403s all live — having never been shown what enforcement would do, which is the exact outcome monitor-mode-by-default exists to prevent. Same one-save flip happens for any unrelated change (adjusting a tripwire path, toggling a good-bot option).
Verified mechanism
CONFIRMED, and slightly worse than claimed. Every link verified on 6f26683.
(a) settings-page.php:13 reads the option raw. No option_webdecoy_options/pre_option_* filter exists in the plugin and register_setting (webdecoy.php:2082) declares no 'default', so nothing supplies defaults to that read.
(b) monitor_mode => true exists ONLY in the runtime merge at webdecoy.php:314-315. I audited every writer of webdecoy_options — activator set_default_options() (defaults at includes/class-webdecoy-activator.php:162-166), update_options_raw() called from maybe_upgrade (webdecoy.php:433-438, touches only block_duration), store_cloud_credentials/clear_cloud_credentials (webdecoy.php:3155, 3187), and sanitize_options. None ever persists the key.
(c) settings-page.php:487 checked(!empty($options['monitor_mode'])) therefore emits no attribute -> unchecked. BROADER THAN CLAIMED: the activator's defaults array also omits monitor_mode, so a brand-new 2.3.2 install renders it unchecked too, not just upgrades.
(d) All tab divs are inside the one <form action="options.php"> (tab switching is CSS-only; #tab-blocking-tab at line 476 is emitted while the active tab is detection), so any Save posts the whole group and an unchecked box posts nothing.
(e) sanitize_options rebuilds from [] (webdecoy.php:2095) and webdecoy.php:2141 writes !empty($input['monitor_mode']) = false.
(f) The steering in the scenario is exact: render_state_notices() tests proxy_misconfigured() (webdecoy.php:466) BEFORE monitor mode (webdecoy.php:495), so a proxied site sees only the proxy notice, whose primary button is "Configure trusted proxies"; that field is settings-page.php:81, same form. After that one Save, monitor_mode is false AND the proxy reason is resolved, so suppression_reason() returns '' and blocking, throttling and 403s all go live simultaneously — with no monitoring period ever observed.
Refutation attempts that failed: no hidden companion input near line 486; grep monitor_mode over JS = 0 hits; maybe_upgrade does not seed the key; the WooCommerce path is unaffected (injected merged options, and suppressed() prefers webdecoy()->enforcement_suppressed(), class-webdecoy-woocommerce.php:449-457). No test references monitor_mode (grep -rn monitor tests/ = 0), so the 75/75 pass says nothing here.
Blocker: the admin notice the owner just read says "WebDecoy is in monitor mode" while the checkbox says otherwise, and the release's headline safety promise is destroyed by one unrelated Save — including the Save the plugin's own error notice tells them to make.
Fix
Persist the new default so the stored array agrees with the effective state — this fixes the checkbox and every future raw reader, rather than patching the render site.
-
webdecoy.php:433-438, in maybe_upgrade(), replace:
$saved = get_option('webdecoy_options', []);
if (is_array($saved) && isset($saved['block_duration']) && (int) $saved['block_duration'] === 24) {
$saved['block_duration'] = 1;
$this->update_options_raw($saved);
$this->load_options();
}
with:
$saved = get_option('webdecoy_options', []);
if (!is_array($saved)) {
$saved = [];
}
$dirty = false;
// 2.3.2: persist the new monitor_mode default. load_options() merges it in
// at runtime, but the settings form reads the stored array directly — without
// this the checkbox renders unchecked and the next save of ANY setting writes
// false, silently turning full enforcement on.
if (!array_key_exists('monitor_mode', $saved)) {
$saved['monitor_mode'] = true;
$dirty = true;
}
if (isset($saved['block_duration']) && (int) $saved['block_duration'] === 24) {
$saved['block_duration'] = 1;
$dirty = true;
}
if ($dirty) {
$this->update_options_raw($saved);
$this->load_options();
}
Ordering is correct: maybe_upgrade is hooked on admin_init (webdecoy.php:812), and wp-admin/options.php requires admin.php (which fires admin_init) before processing the POST, so the key is seeded both on the render that precedes the save and on the save request itself.
- includes/class-webdecoy-activator.php:162-166 — add
'monitor_mode' => true, to the "Blocking Settings" block and change 'block_duration' => 24, to 'block_duration' => 1,, so a fresh install's stored array matches the 2.3.2 defaults without depending on the migration.
Optional one-line belt at the reported line, admin/partials/settings-page.php:487, if you want the UI correct even when the option row predates the migration:
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.
admin/partials/settings-page.php:487 · severity
blocker· lensupgrade-and-defaultsThe defect
The Monitor mode checkbox reads the RAW stored option array (
$options = get_option('webdecoy_options', [])at line 13), which never contains the newmonitor_modekey after an upgrade — so it renders UNCHECKED on every existing install while the plugin is actually in monitor mode. Because an unchecked box submits nothing andsanitize_options()(webdecoy.php:2141) rebuilds the array from scratch with$sanitized['monitor_mode'] = !empty($input['monitor_mode']), the very next save of the settings form persistsfalseand turns full enforcement on. The release's central safety promise survives exactly until the owner saves settings once, for any reason.Failure scenario
Site on 2.3.0 upgrades to 2.3.2.
load_options()mergesmonitor_mode => true, so the plugin is suppressed andrender_state_notices()prints either "WebDecoy is in monitor mode" or the proxy-misconfigured notice whose primary button is "Configure trusted proxies". The owner clicks through to Settings → Blocking, where the Monitor mode box is displayed unchecked (contradicting the notice they just read). They paste173.245.48.0/20into Trusted proxies and click Save Changes. POST contains nowebdecoy_options[monitor_mode], sofalseis stored. In one save the site goes from fully suppressed to fully enforcing — blocking, throttling and 403s all live — having never been shown what enforcement would do, which is the exact outcome monitor-mode-by-default exists to prevent. Same one-save flip happens for any unrelated change (adjusting a tripwire path, toggling a good-bot option).Verified mechanism
CONFIRMED, and slightly worse than claimed. Every link verified on 6f26683.
(a) settings-page.php:13 reads the option raw. No
option_webdecoy_options/pre_option_*filter exists in the plugin andregister_setting(webdecoy.php:2082) declares no'default', so nothing supplies defaults to that read.(b)
monitor_mode => trueexists ONLY in the runtime merge at webdecoy.php:314-315. I audited every writer ofwebdecoy_options— activatorset_default_options()(defaults at includes/class-webdecoy-activator.php:162-166),update_options_raw()called frommaybe_upgrade(webdecoy.php:433-438, touches onlyblock_duration),store_cloud_credentials/clear_cloud_credentials(webdecoy.php:3155, 3187), andsanitize_options. None ever persists the key.(c) settings-page.php:487
checked(!empty($options['monitor_mode']))therefore emits no attribute -> unchecked. BROADER THAN CLAIMED: the activator's defaults array also omitsmonitor_mode, so a brand-new 2.3.2 install renders it unchecked too, not just upgrades.(d) All tab divs are inside the one
<form action="options.php">(tab switching is CSS-only;#tab-blocking-tabat line 476 is emitted while the active tab isdetection), so any Save posts the whole group and an unchecked box posts nothing.(e) sanitize_options rebuilds from
[](webdecoy.php:2095) and webdecoy.php:2141 writes!empty($input['monitor_mode'])= false.(f) The steering in the scenario is exact: render_state_notices() tests proxy_misconfigured() (webdecoy.php:466) BEFORE monitor mode (webdecoy.php:495), so a proxied site sees only the proxy notice, whose primary button is "Configure trusted proxies"; that field is settings-page.php:81, same form. After that one Save, monitor_mode is false AND the proxy reason is resolved, so suppression_reason() returns '' and blocking, throttling and 403s all go live simultaneously — with no monitoring period ever observed.
Refutation attempts that failed: no hidden companion input near line 486;
grep monitor_modeover JS = 0 hits; maybe_upgrade does not seed the key; the WooCommerce path is unaffected (injected merged options, and suppressed() prefers webdecoy()->enforcement_suppressed(), class-webdecoy-woocommerce.php:449-457). No test references monitor_mode (grep -rn monitor tests/= 0), so the 75/75 pass says nothing here.Blocker: the admin notice the owner just read says "WebDecoy is in monitor mode" while the checkbox says otherwise, and the release's headline safety promise is destroyed by one unrelated Save — including the Save the plugin's own error notice tells them to make.
Fix
Persist the new default so the stored array agrees with the effective state — this fixes the checkbox and every future raw reader, rather than patching the render site.
webdecoy.php:433-438, in
maybe_upgrade(), replace:with:
Ordering is correct:
maybe_upgradeis hooked onadmin_init(webdecoy.php:812), and wp-admin/options.php requires admin.php (which fires admin_init) before processing the POST, so the key is seeded both on the render that precedes the save and on the save request itself.'monitor_mode' => true,to the "Blocking Settings" block and change'block_duration' => 24,to'block_duration' => 1,, so a fresh install's stored array matches the 2.3.2 defaults without depending on the migration.Optional one-line belt at the reported line, admin/partials/settings-page.php:487, if you want the UI correct even when the option row predates the migration:
Blocks the 2.3.2 release. Refs #54.