You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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:217 · severity high · lens woo
The defect
track_attempt() inserts one row per checkout POST, not per order: woocommerce_checkout_order_processed fires on every submission that passes validation and WC_Checkout::create_order() reuses the order held in the order_awaiting_payment session. A single customer retrying a declined card therefore manufactures the multi-attempt evidence that detect_card_testing() reads, and the new success filter cannot suppress it because those rows are not 'success'.
Failure scenario
Customer's card is declined and she resubmits: t=0 row1, t=25s row2, t=50s row3 — all order_id 1234, all status='attempt'. On her 4th submit detect_card_testing() sees 3 rows with consecutive gaps of 25s → rapid_count = 2 → Pattern 4 (line 190) returns true → line 91 refuses the order with "Suspicious checkout activity detected.", and it stays refused for the whole 3600s window, so she cannot buy at all. If the gateway also fires woocommerce_order_status_failed, track_failure() (line 269) has no LIMIT and flips every pending row for that order to 'declined' in one statement, so Pattern 2 (declined >= 3, line 163) trips on the same single customer even sooner. With the sitewide block gone there is not even a block-table row for the merchant to find and clear.
Verified mechanism
CONFIRMED. I tried to break each link and could not.
Hook wiring: webdecoy.php:851 binds track_checkout_attempt to woocommerce_checkout_order_processed; includes/class-webdecoy-woocommerce.php:759 binds track_attempt to woocommerce_store_api_checkout_order_processed. Both fire after create_order() and BEFORE payment is attempted, so a declined or abandoned payment still leaves a row.
track_attempt() — includes/class-webdecoy-woocommerce.php:214 — is an unconditional $wpdb->insert with no order_id dedupe. One row per POST, as claimed.
Order reuse is real: WC_Checkout::create_order() resumes the order in order_awaiting_payment when the cart hash matches and status is pending/failed, so every retry row carries the same order_id.
get_recent_attempts($ip, 3600, false) (line 298) excludes only status = 'success'. Retry rows are 'attempt', so the new filter genuinely cannot suppress them.
Pattern 4 arithmetic checks out: 3 rows, gaps 25s and 25s, both < 30 → rapid_count = 2 → >= 2 at line 190 → true.
Line 92 wc_add_notice(..., 'error') runs inside woocommerce_checkout_process, which WC calls from validate_checkout(); process_checkout() then requires 0 === wc_notice_count('error') before create_order(). The order is never created. She cannot buy. Confirmed.
Duration: correct, and worse than stated. Because the refusal aborts before order creation, no new rows accrue, so it does expire — but created_at is written with current_time('mysql') (line 220) against a gmdate() threshold (line 288), so the effective window is 3600 + UTC offset. Europe/Berlin ≈ 3h, Asia/Tokyo ≈ 10h. (Not re-reporting The UTC/site-local timestamp split still exists in the checkout_attempts and detections tables #55; noting it inflates this blast radius. Conversely, on UTC-negative sites the threshold exceeds every stored value, so card-testing detection is dead there entirely.)
ONE CORRECTION: "Pattern 2 … trips on the same single customer even sooner" is wrong. Pattern 2 still needs 3 rows, so it is not earlier than Pattern 4 — it is an additional path that drops Pattern 4's <30s requirement, so slow retries trip too. The unLIMITed UPDATE at lines 268-273 does flip every 'attempt' row for that (ip, order_id) in one statement, as claimed, but it depends on the gateway transitioning the order to failed, and on POST 2+ the order is often already failed so WC_Order::status_transition() fires nothing. The claim hedged this correctly; only "sooner" is inaccurate. Mechanism and fix location are unaffected.
STRENGTHENING — Pattern 1 is far more reachable and the claim missed it. Line 157 needs no timing at all, and amount is the order total (line 218), identical on every retry row for one order. So for ANY order under $5.00 three checkout submissions of that one order — declined card, an abandoned PayPal redirect, a reload — give three rows with the same sub-$5 amount → count($small_amounts) >= 3 → refused. That is exactly the customer profile the changelog names as fixed ("digital downloads, donations, tips and add-ons"); the fix only excluded status = 'success', so unsuccessful retries of one small order still read as an attack. The changelog's headline WooCommerce claim is incomplete in the case it names.
WHY IT BLOCKS RELEASE (this is the decisive part). check_checkout() consults suppressed() only on the is_blocked() branch at line 65. Lines 80 (velocity), 91 (card testing) and 105 (bot detection) refuse the order with NO suppression check — three lines below the author's own comment saying 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." The Store API twin has the same split: line 708 checks $woo->suppressed(), lines 724 and 735 do not. So this false positive fires at full strength in monitor mode, which webdecoy.php:231 makes the default for every existing install on upgrade, while the notice at webdecoy.php:508 reads "Everything is detected and logged; nothing is blocked." WEBDECOY_DISABLE is honored only in early_check() (webdecoy.php:1024), so the documented FTP-only emergency off switch does not reach this path either. is_allowlisted() is not consulted here either, so the merchant's own IP is not exempt.
The row inflation itself is not a regression — main behaves identically and additionally wrote a 24h sitewide block — but the un-gated checkout path makes the release's central promise false in the one place that costs money, on a WooCommerce store, by default. There are no checkout tests in tests/, so nothing verified this.
Fix
Two parts. (a) is required for this release; (b) is the root-cause fix and is a follow-up.
(a) REQUIRED — put the three checkout enforcement branches behind the same gate as everything else, so the false positive is harmless on every default (monitor-mode) install.
includes/class-webdecoy-woocommerce.php — hoist the test above line 79 and skip only the notice when suppressed, keeping log_detection() so monitor mode still records the counterfactual:
$suppressed = $this->suppressed(); // insert before line 79
then at line 80 (velocity), line 91 (card testing) and line 105 (bot detection), wrap each wc_add_notice(...) in if (!$suppressed) { ... }, leaving the log_detection() call and the return as they are. Example for line 91:
if ($this->detect_card_testing($ip)) {
$this->log_detection($ip, 'card_testing');
if (!$suppressed) {
wc_add_notice(__('Suspicious checkout activity detected.', 'webdecoy'), 'error');
}
return;
}
Mirror it in the Store API closure: includes/class-webdecoy-woocommerce.php:724-743 — compute $suppressed = $woo->suppressed(); once (it is already called at line 708) and throw the RouteException at lines 727 and 738 only when !$suppressed, after the existing log_detection_public() call.
(b) FOLLOW-UP — the evidence inflation. Do NOT fix this by deduping rows per order_id in track_attempt() (includes/class-webdecoy-woocommerce.php:214): a real carding bot cycling cards through one reused cart produces exactly one order_id, so a per-order dedupe would collapse the attack to a single row and blind Patterns 1/2/4 and the velocity counter in check_velocity(). Narrow the patterns instead — in detect_card_testing() (includes/class-webdecoy-woocommerce.php:139), gate Patterns 1, 2 and 4 on evidence that one order cannot manufacture:
and require $multi before lines 157, 166 and 190. Pattern 3 (line 173) already keys on distinct cards and needs no change. Same treatment for check_velocity() (line 128): count distinct order_ids, not rows, so one customer's retries of one order cannot consume the limit.
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:217 · severity
high· lenswooThe defect
track_attempt() inserts one row per checkout POST, not per order: woocommerce_checkout_order_processed fires on every submission that passes validation and WC_Checkout::create_order() reuses the order held in the order_awaiting_payment session. A single customer retrying a declined card therefore manufactures the multi-attempt evidence that detect_card_testing() reads, and the new success filter cannot suppress it because those rows are not 'success'.
Failure scenario
Customer's card is declined and she resubmits: t=0 row1, t=25s row2, t=50s row3 — all order_id 1234, all status='attempt'. On her 4th submit detect_card_testing() sees 3 rows with consecutive gaps of 25s → rapid_count = 2 → Pattern 4 (line 190) returns true → line 91 refuses the order with "Suspicious checkout activity detected.", and it stays refused for the whole 3600s window, so she cannot buy at all. If the gateway also fires woocommerce_order_status_failed, track_failure() (line 269) has no LIMIT and flips every pending row for that order to 'declined' in one statement, so Pattern 2 (declined >= 3, line 163) trips on the same single customer even sooner. With the sitewide block gone there is not even a block-table row for the merchant to find and clear.
Verified mechanism
CONFIRMED. I tried to break each link and could not.
webdecoy.php:851bindstrack_checkout_attempttowoocommerce_checkout_order_processed;includes/class-webdecoy-woocommerce.php:759bindstrack_attempttowoocommerce_store_api_checkout_order_processed. Both fire aftercreate_order()and BEFORE payment is attempted, so a declined or abandoned payment still leaves a row.track_attempt()—includes/class-webdecoy-woocommerce.php:214— is an unconditional$wpdb->insertwith no order_id dedupe. One row per POST, as claimed.WC_Checkout::create_order()resumes the order inorder_awaiting_paymentwhen the cart hash matches and status is pending/failed, so every retry row carries the same order_id.get_recent_attempts($ip, 3600, false)(line 298) excludes onlystatus = 'success'. Retry rows are'attempt', so the new filter genuinely cannot suppress them.>= 2at line 190 → true.wc_add_notice(..., 'error')runs insidewoocommerce_checkout_process, which WC calls fromvalidate_checkout();process_checkout()then requires0 === wc_notice_count('error')beforecreate_order(). The order is never created. She cannot buy. Confirmed.created_atis written withcurrent_time('mysql')(line 220) against agmdate()threshold (line 288), so the effective window is 3600 + UTC offset. Europe/Berlin ≈ 3h, Asia/Tokyo ≈ 10h. (Not re-reporting The UTC/site-local timestamp split still exists in the checkout_attempts and detections tables #55; noting it inflates this blast radius. Conversely, on UTC-negative sites the threshold exceeds every stored value, so card-testing detection is dead there entirely.)ONE CORRECTION: "Pattern 2 … trips on the same single customer even sooner" is wrong. Pattern 2 still needs 3 rows, so it is not earlier than Pattern 4 — it is an additional path that drops Pattern 4's <30s requirement, so slow retries trip too. The unLIMITed UPDATE at lines 268-273 does flip every
'attempt'row for that (ip, order_id) in one statement, as claimed, but it depends on the gateway transitioning the order tofailed, and on POST 2+ the order is often alreadyfailedsoWC_Order::status_transition()fires nothing. The claim hedged this correctly; only "sooner" is inaccurate. Mechanism and fix location are unaffected.STRENGTHENING — Pattern 1 is far more reachable and the claim missed it. Line 157 needs no timing at all, and
amountis the order total (line 218), identical on every retry row for one order. So for ANY order under $5.00 three checkout submissions of that one order — declined card, an abandoned PayPal redirect, a reload — give three rows with the same sub-$5 amount →count($small_amounts) >= 3→ refused. That is exactly the customer profile the changelog names as fixed ("digital downloads, donations, tips and add-ons"); the fix only excludedstatus = 'success', so unsuccessful retries of one small order still read as an attack. The changelog's headline WooCommerce claim is incomplete in the case it names.WHY IT BLOCKS RELEASE (this is the decisive part).
check_checkout()consultssuppressed()only on theis_blocked()branch at line 65. Lines 80 (velocity), 91 (card testing) and 105 (bot detection) refuse the order with NO suppression check — three lines below the author's own comment saying 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." The Store API twin has the same split: line 708 checks$woo->suppressed(), lines 724 and 735 do not. So this false positive fires at full strength in monitor mode, whichwebdecoy.php:231makes the default for every existing install on upgrade, while the notice atwebdecoy.php:508reads "Everything is detected and logged; nothing is blocked."WEBDECOY_DISABLEis honored only inearly_check()(webdecoy.php:1024), so the documented FTP-only emergency off switch does not reach this path either.is_allowlisted()is not consulted here either, so the merchant's own IP is not exempt.The row inflation itself is not a regression —
mainbehaves identically and additionally wrote a 24h sitewide block — but the un-gated checkout path makes the release's central promise false in the one place that costs money, on a WooCommerce store, by default. There are no checkout tests intests/, so nothing verified this.Fix
Two parts. (a) is required for this release; (b) is the root-cause fix and is a follow-up.
(a) REQUIRED — put the three checkout enforcement branches behind the same gate as everything else, so the false positive is harmless on every default (monitor-mode) install.
includes/class-webdecoy-woocommerce.php— hoist the test above line 79 and skip only the notice when suppressed, keepinglog_detection()so monitor mode still records the counterfactual:then at line 80 (velocity), line 91 (card testing) and line 105 (bot detection), wrap each
wc_add_notice(...)inif (!$suppressed) { ... }, leaving thelog_detection()call and thereturnas they are. Example for line 91:Mirror it in the Store API closure:
includes/class-webdecoy-woocommerce.php:724-743— compute$suppressed = $woo->suppressed();once (it is already called at line 708) and throw theRouteExceptionat lines 727 and 738 only when!$suppressed, after the existinglog_detection_public()call.(b) FOLLOW-UP — the evidence inflation. Do NOT fix this by deduping rows per order_id in
track_attempt()(includes/class-webdecoy-woocommerce.php:214): a real carding bot cycling cards through one reused cart produces exactly one order_id, so a per-order dedupe would collapse the attack to a single row and blind Patterns 1/2/4 and the velocity counter incheck_velocity(). Narrow the patterns instead — indetect_card_testing()(includes/class-webdecoy-woocommerce.php:139), gate Patterns 1, 2 and 4 on evidence that one order cannot manufacture:and require
$multibefore lines 157, 166 and 190. Pattern 3 (line 173) already keys on distinct cards and needs no change. Same treatment forcheck_velocity()(line 128): count distinct order_ids, not rows, so one customer's retries of one order cannot consume the limit.Blocks the 2.3.2 release. Refs #54.