Found by the adversarial review of PR #54 (2.3.2 safety release). Verified — the reviewer
attempted to refute this and could not.
includes/class-webdecoy-woocommerce.php:80 · severity high · lens availability
The defect
The three checkout detection paths refuse the order regardless of suppressed(), so monitor mode — now the default for every existing install — still loses WooCommerce orders while the admin notice states nothing is blocked.
Failure scenario
Store behind Cloudflare with trusted proxies not configured (the exact condition this release exists to handle). proxy_misconfigured() is true, so render_state_notices() (webdecoy.php:448) prints "WebDecoy is not blocking... Only blocking, rate limiting and the 403 page are withheld." But getIP() returns REMOTE_ADDR, so every shopper resolves to the same Cloudflare edge address. check_velocity() counts non-success rows for that one address against checkout_velocity_limit = 5 over one hour, so after 5 store-wide checkouts in an hour EVERY subsequent shopper gets wc_add_notice('Too many checkout attempts. Please try again later.') at line 81 and cannot complete an order. Worse on stores using BACS/cheque/COD, where woocommerce_payment_complete never fires so track_payment() never flips status to 'success' and the new status <> 'success' filter excludes nothing — every legitimate order counts as an attempt. Line 65 immediately above proves the author's own intent (if ($this->blocker->is_blocked($ip) && !$this->suppressed()), with a comment that suppression "has to hold on the checkout path too, or the admin notice promising nothing is blocked is a lie to a merchant losing orders") — lines 80, 91 and 105, and the identical Store API/Blocks path at lines 724 and 735, omit the !$this->suppressed() conjunct.
Verified mechanism
CONFIRMED — every link in the chain holds, and the author's own comment states the intent the code fails to implement.
Note on which tree I verified: includes/class-webdecoy-woocommerce.php is UNCOMMITTED in the working tree (git status shows M). git show HEAD:includes/class-webdecoy-woocommerce.php | grep suppressed returns nothing — suppressed() and the two guarded is_blocked checks exist only in the working tree. So the finding was written against the working tree, which is the newer/better state. In HEAD as pushed to PR #54, there is no suppression check anywhere on the checkout path, so the defect is strictly worse there. Verified against the working tree, the more favorable state.
Chain verified line by line:
-
Suppression is asymmetric, exactly as claimed. check_checkout() consults suppressed() at line 65 only. Lines 80 (!$this->check_velocity($ip)), 91 (detect_card_testing), 105 (shouldBlock) each wc_add_notice(..., 'error') and return with no suppression conjunct. Store API closure: line 708 consults !$woo->suppressed(); lines 724 and 735 throw RouteException 429/403 without it. Line numbers match the claim exactly.
-
The refusal is real, not cosmetic. webdecoy.php:849 hooks check_checkout on woocommerce_checkout_process, which is precisely the hook where WC_Checkout::process_checkout() gates order creation on 0 === wc_notice_count('error'). An error notice there means no order. The Store API path throws 429/403 outright. The wrapper at webdecoy.php:1930 adds no gate, and registration at webdecoy.php:848 is gated only on protect_checkout, which defaults to true (webdecoy.php:291).
-
Defaults line up: monitor_mode => true (webdecoy.php:230), protect_checkout => true (291), checkout_velocity_limit => 5 (292), checkout_velocity_window => 3600 (293).
-
The shared-IP premise holds, and is broader than the claim states. proxy_misconfigured() (webdecoy.php:375) is forwarding_headers_present() && get_trusted_proxies() === [], and SignalCollector::getIP() (sdk/src/SignalCollector.php:136) then returns REMOTE_ADDR = the proxy. Additionally — pre-existing, not part of this diff, but it widens the blast radius — WebDecoy_WooCommerce::get_client_ip() at line 415-418 constructs new \WebDecoy\SignalCollector() with NO trusted proxies, so the checkout path collapses every shopper onto the proxy address even on a CORRECTLY configured Cloudflare store, where suppressed() is false and the throttle fires with no notice shown at all.
-
The status <> 'success' filter really does exclude nothing on the named gateways. Rows are only written by track_attempt (line 203) on woocommerce_checkout_order_processed, i.e. after order creation, and only track_payment (line 229) flips them to 'success' via woocommerce_payment_complete. BACS/cheque/COD call update_status(), never payment_complete(), so rows stay 'attempt' and count. An amplifier the claim missed: for off-site gateways (PayPal Standard IPN) track_payment resolves $ip from the gateway's request, so WHERE ip_address = %s AND order_id = %d matches nothing and the row stays 'attempt' permanently — same outcome on any redirect gateway. Abandoned pending orders and declined/failed rows count too.
-
The promise is unconditional and falsified. webdecoy.php:512 "Everything is detected and logged; nothing is blocked." webdecoy.php:488 "Only blocking, rate limiting and the 403 page are withheld." webdecoy.php:483 "blocking anyone would block everyone." The Store API path at 727/738 literally serves a 403 and a 429 while those notices are on screen. And suppression_reason()'s own docblock (webdecoy.php:384-385) says "only the act of blocking, throttling or serving a 403 is withheld."
-
Author intent is on the record. The comment the fix author wrote at lines 61-64 — "that has to hold on the checkout path too, or the admin notice promising nothing is blocked is a lie to a merchant losing orders" — is applied at exactly one of five sites. This is an incomplete edit, not a design decision.
One imprecision, non-fatal to the finding but worth stating so the fix isn't mis-scoped: the claim's headline sentence "after 5 store-wide checkouts in an hour EVERY subsequent shopper" is over-broad for in-request card gateways (Stripe/WooPayments), where payment_complete does fire and rows flip to 'success' and are correctly excluded. The claim's own next sentence narrows this to BACS/cheque/COD, which is right — and the redirect-gateway case above makes the realistic surface larger, not smaller. detect_card_testing's Pattern 4 (line 178: three attempts, two consecutive gaps under 30s) needs only 3 rows, so the card-testing path trips before velocity on a busy shared-IP store.
blocksRelease: yes. Default settings on a proxied WooCommerce store lose real orders while the plugin displays a notice saying it is not blocking. That is the "this plugin blocked my customers" review, on the release whose stated purpose is "safe to run on a stranger's site."
Fix
Gate the refusal on suppressed() at all five sites, and log BEFORE the gate so detection/logging still continue in suppressed states (a bare && !$this->suppressed() on the if would also skip log_detection, breaking the other half of the promise). The return must move inside the guard so a suppressed request falls through to the remaining checks and gets recorded by them too.
includes/class-webdecoy-woocommerce.php:80-112 (classic path) — replace the three blocks with:
// Check velocity
if (!$this->check_velocity($ip)) {
$this->log_detection($ip, 'velocity_exceeded');
if (!$this->suppressed()) {
wc_add_notice(
__('Too many checkout attempts. Please try again later.', 'webdecoy'),
'error'
);
return;
}
}
// Check for card testing patterns
if ($this->detect_card_testing($ip)) {
$this->log_detection($ip, 'card_testing');
if (!$this->suppressed()) {
wc_add_notice(
__('Suspicious checkout activity detected.', 'webdecoy'),
'error'
);
return;
}
}
// Run bot detection
$detector = new WebDecoy_Detector($this->options);
$result = $detector->analyze();
if ($result->shouldBlock($this->options['min_score_to_block'])) {
$this->log_detection($ip, 'bot_detection', $result->getScore());
if (!$this->suppressed()) {
wc_add_notice(
__('Your checkout has been blocked due to suspicious activity.', 'webdecoy'),
'error'
);
}
}
includes/class-webdecoy-woocommerce.php:724-743 (Store API/Blocks path) — same shape; log_detection_public already runs first, so only the throws need wrapping:
if (!$woo->check_velocity_public($ip)) {
$woo->log_detection_public($ip, 'velocity_exceeded');
if (!$woo->suppressed()) {
throw new \Automattic\WooCommerce\StoreApi\Exceptions\RouteException(
'webdecoy_velocity',
esc_html(__('Too many checkout attempts. Please try again later.', 'webdecoy')),
429
);
}
}
if ($woo->detect_card_testing_public($ip)) {
$woo->log_detection_public($ip, 'card_testing');
if (!$woo->suppressed()) {
throw new \Automattic\WooCommerce\StoreApi\Exceptions\RouteException(
'webdecoy_carding',
esc_html(__('Suspicious checkout activity detected.', 'webdecoy')),
403
);
}
}
Optional consistency, not required for correctness: call the same record_suppressed_action() that enforce_block() uses inside each suppressed branch, so these withheld checkout refusals appear in the monitor-mode notice's "would have acted on N requests" count instead of being invisible.
Two adjacent items for follow-up issues, NOT part of this fix: (1) includes/class-webdecoy-woocommerce.php:415-418 constructs new \WebDecoy\SignalCollector() with no trusted proxies, so the checkout path ignores trusted_proxies even when correctly configured and every shopper on any proxied store shares one identity — pre-existing, but it means this throttle is store-wide on properly-configured stores too, where no suppression state applies. (2) track_payment (line 229) can never match its row for redirect gateways because the IPN request carries the gateway's IP, so 'attempt' rows are permanent on PayPal Standard-style stores.
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.
includes/class-webdecoy-woocommerce.php:80 · severity
high· lensavailabilityThe defect
The three checkout detection paths refuse the order regardless of
suppressed(), so monitor mode — now the default for every existing install — still loses WooCommerce orders while the admin notice states nothing is blocked.Failure scenario
Store behind Cloudflare with trusted proxies not configured (the exact condition this release exists to handle).
proxy_misconfigured()is true, sorender_state_notices()(webdecoy.php:448) prints "WebDecoy is not blocking... Only blocking, rate limiting and the 403 page are withheld." ButgetIP()returns REMOTE_ADDR, so every shopper resolves to the same Cloudflare edge address.check_velocity()counts non-success rows for that one address againstcheckout_velocity_limit = 5over one hour, so after 5 store-wide checkouts in an hour EVERY subsequent shopper getswc_add_notice('Too many checkout attempts. Please try again later.')at line 81 and cannot complete an order. Worse on stores using BACS/cheque/COD, wherewoocommerce_payment_completenever fires sotrack_payment()never flips status to 'success' and the newstatus <> 'success'filter excludes nothing — every legitimate order counts as an attempt. Line 65 immediately above proves the author's own intent (if ($this->blocker->is_blocked($ip) && !$this->suppressed()), with a comment that suppression "has to hold on the checkout path too, or the admin notice promising nothing is blocked is a lie to a merchant losing orders") — lines 80, 91 and 105, and the identical Store API/Blocks path at lines 724 and 735, omit the!$this->suppressed()conjunct.Verified mechanism
CONFIRMED — every link in the chain holds, and the author's own comment states the intent the code fails to implement.
Note on which tree I verified:
includes/class-webdecoy-woocommerce.phpis UNCOMMITTED in the working tree (git statusshowsM).git show HEAD:includes/class-webdecoy-woocommerce.php | grep suppressedreturns nothing —suppressed()and the two guardedis_blockedchecks exist only in the working tree. So the finding was written against the working tree, which is the newer/better state. In HEAD as pushed to PR #54, there is no suppression check anywhere on the checkout path, so the defect is strictly worse there. Verified against the working tree, the more favorable state.Chain verified line by line:
Suppression is asymmetric, exactly as claimed.
check_checkout()consultssuppressed()at line 65 only. Lines 80 (!$this->check_velocity($ip)), 91 (detect_card_testing), 105 (shouldBlock) eachwc_add_notice(..., 'error')and return with no suppression conjunct. Store API closure: line 708 consults!$woo->suppressed(); lines 724 and 735 throwRouteException429/403 without it. Line numbers match the claim exactly.The refusal is real, not cosmetic.
webdecoy.php:849hookscheck_checkoutonwoocommerce_checkout_process, which is precisely the hook whereWC_Checkout::process_checkout()gates order creation on0 === wc_notice_count('error'). An error notice there means no order. The Store API path throws 429/403 outright. The wrapper atwebdecoy.php:1930adds no gate, and registration atwebdecoy.php:848is gated only onprotect_checkout, which defaults totrue(webdecoy.php:291).Defaults line up:
monitor_mode => true(webdecoy.php:230),protect_checkout => true(291),checkout_velocity_limit => 5(292),checkout_velocity_window => 3600(293).The shared-IP premise holds, and is broader than the claim states.
proxy_misconfigured()(webdecoy.php:375) isforwarding_headers_present() && get_trusted_proxies() === [], andSignalCollector::getIP()(sdk/src/SignalCollector.php:136) then returns REMOTE_ADDR = the proxy. Additionally — pre-existing, not part of this diff, but it widens the blast radius —WebDecoy_WooCommerce::get_client_ip()at line 415-418 constructsnew \WebDecoy\SignalCollector()with NO trusted proxies, so the checkout path collapses every shopper onto the proxy address even on a CORRECTLY configured Cloudflare store, wheresuppressed()is false and the throttle fires with no notice shown at all.The
status <> 'success'filter really does exclude nothing on the named gateways. Rows are only written bytrack_attempt(line 203) onwoocommerce_checkout_order_processed, i.e. after order creation, and onlytrack_payment(line 229) flips them to 'success' viawoocommerce_payment_complete. BACS/cheque/COD callupdate_status(), neverpayment_complete(), so rows stay'attempt'and count. An amplifier the claim missed: for off-site gateways (PayPal Standard IPN)track_paymentresolves$ipfrom the gateway's request, soWHERE ip_address = %s AND order_id = %dmatches nothing and the row stays'attempt'permanently — same outcome on any redirect gateway. Abandoned pending orders and declined/failed rows count too.The promise is unconditional and falsified. webdecoy.php:512 "Everything is detected and logged; nothing is blocked." webdecoy.php:488 "Only blocking, rate limiting and the 403 page are withheld." webdecoy.php:483 "blocking anyone would block everyone." The Store API path at 727/738 literally serves a 403 and a 429 while those notices are on screen. And
suppression_reason()'s own docblock (webdecoy.php:384-385) says "only the act of blocking, throttling or serving a 403 is withheld."Author intent is on the record. The comment the fix author wrote at lines 61-64 — "that has to hold on the checkout path too, or the admin notice promising nothing is blocked is a lie to a merchant losing orders" — is applied at exactly one of five sites. This is an incomplete edit, not a design decision.
One imprecision, non-fatal to the finding but worth stating so the fix isn't mis-scoped: the claim's headline sentence "after 5 store-wide checkouts in an hour EVERY subsequent shopper" is over-broad for in-request card gateways (Stripe/WooPayments), where
payment_completedoes fire and rows flip to 'success' and are correctly excluded. The claim's own next sentence narrows this to BACS/cheque/COD, which is right — and the redirect-gateway case above makes the realistic surface larger, not smaller.detect_card_testing's Pattern 4 (line 178: three attempts, two consecutive gaps under 30s) needs only 3 rows, so the card-testing path trips before velocity on a busy shared-IP store.blocksRelease: yes. Default settings on a proxied WooCommerce store lose real orders while the plugin displays a notice saying it is not blocking. That is the "this plugin blocked my customers" review, on the release whose stated purpose is "safe to run on a stranger's site."
Fix
Gate the refusal on
suppressed()at all five sites, and log BEFORE the gate so detection/logging still continue in suppressed states (a bare&& !$this->suppressed()on theifwould also skiplog_detection, breaking the other half of the promise). Thereturnmust move inside the guard so a suppressed request falls through to the remaining checks and gets recorded by them too.includes/class-webdecoy-woocommerce.php:80-112 (classic path) — replace the three blocks with:
includes/class-webdecoy-woocommerce.php:724-743 (Store API/Blocks path) — same shape;
log_detection_publicalready runs first, so only the throws need wrapping:Optional consistency, not required for correctness: call the same
record_suppressed_action()thatenforce_block()uses inside each suppressed branch, so these withheld checkout refusals appear in the monitor-mode notice's "would have acted on N requests" count instead of being invisible.Two adjacent items for follow-up issues, NOT part of this fix: (1)
includes/class-webdecoy-woocommerce.php:415-418constructsnew \WebDecoy\SignalCollector()with no trusted proxies, so the checkout path ignorestrusted_proxieseven when correctly configured and every shopper on any proxied store shares one identity — pre-existing, but it means this throttle is store-wide on properly-configured stores too, where no suppression state applies. (2)track_payment(line 229) can never match its row for redirect gateways because the IPN request carries the gateway's IP, so'attempt'rows are permanent on PayPal Standard-style stores.Blocks the 2.3.2 release. Refs #54.