Skip to content

Safety release 2.3.2 — safe to run on a stranger's site - #54

Merged
cport1 merged 3 commits into
mainfrom
fix/2.3.2-safety
Jul 29, 2026
Merged

Safety release 2.3.2 — safe to run on a stranger's site#54
cport1 merged 3 commits into
mainfrom
fix/2.3.2-safety

Conversation

@cport1

@cport1 cport1 commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Closes #48, #49, #50, #51, #52. Refs WebDecoy/app#476.

The plugin is live on wordpress.org and, on the modal SMB stack (WordPress behind
Cloudflare or a managed host), could take a site offline from a single anonymous
request. This release deliberately makes the plugin do less by default.

The headline behaviour change

Monitor mode is now the default (webdecoy.php:230), for new installs and
existing ones — load_options() does array_merge($defaults, $saved), and
monitor_mode is a new key, so a saved options array does not override it.
Everything is still detected, logged and reported to the cloud. Nothing is blocked,
throttled or 403'd until the owner turns blocking on.

suppression_reason() is the single gate — 'disabled' | 'monitor' | 'proxy' | ''
and the four automatic block sites now route through enforce_block() rather than
calling Blocker::block() directly.

What each fix actually does

Issue Fix
#48 proxy_misconfigured() detects "forwarding header present, no trusted proxy configured" and withholds every IP-keyed action while it holds, with a blocking admin notice naming the header seen.
#49 Blocker::guard() refuses loopback, private/reserved space, a configured trusted-proxy range, and the current request's proxy front door; automatic CIDR blocks capped at /24 (v4) and /48 (v6). Refusals recorded via get_refusals() and a webdecoy_block_refused action.
#50 WEBDECOY_DISABLE recovery constant checked in early_check(); one global monitor-mode switch above the three existing dry-run flags; admin notices for all three suppressed states.
#51 block_duration 24h → 1h; honeypot path now passes a duration (it previously passed none, so it blocked permanently); block table is UTC end to end.
#52 Successful orders excluded from card-testing patterns and the checkout velocity limit; the checkout path refuses the order and logs instead of writing a sitewide IP block.
app#476 Actor feed writes advisory intel instead of block rows; maybe_upgrade() purges the rows it previously wrote.

getIP() is deliberately unchanged

sdk/src/SignalCollector.php:127-170 is correct. With no trusted proxy configured
it distrusts CF-Connecting-IP / X-Real-IP / X-Forwarded-For and returns
REMOTE_ADDR. Trusting those headers would let any client choose its own identity,
evade every block by rotating a header, and frame an arbitrary third party into the
block table — strictly worse than the bug being fixed. The defect was that nothing
detected the configuration mismatch.

Timezone fix, in full

expires_at was written with gmdate() (UTC) and compared against
current_time('mysql') (site-local), so on any site with a UTC offset of +1 or
greater a short block was already expired the moment it was written
— blocking
silently did nothing there. Converted: all reads and writes in
class-webdecoy-blocker.php, the NOW() comparison in statistics-page.php:106
(MySQL NOW() resolves in the DB session time zone, which WordPress never sets),
and the admin display in blocked-ips-page.php now goes through get_date_from_gmt()
so it does not show UTC labelled as local.

Checks

  • 75/75 tests pass (php tests/run.php)
  • phpcs 0 errors
  • phpstan clean except one pre-existing error in class-webdecoy-updater.php:76, untouched here

Review status — read before merging

The adversarial review I ran over this diff did not complete: 5 of 6 review lenses
and all 5 verification agents died on a session limit. Three findings from the one lens
that finished were checked by hand and fixed in this PR (the NOW() comparison, the
admin date display, and an option write per throttled request that would have turned a
bot flood into an options-table flood).

Two findings remain unverified, both pre-existing and out of scope here — filed
separately rather than fixed in a safety release:

The remaining lenses — gate completeness across paths that bypass early_check(),
guard() edge cases (IPv4-mapped IPv6, CGNAT, load order), the Woo Store API path, and
the upgrade/defaults merge — have not been reviewed by anything other than the author.
Worth a second pass before this goes to wordpress.org.

The plugin is live on wordpress.org and, on the modal SMB stack, could take a
site offline from a single anonymous request. This release deliberately makes it
do less by default.

Monitor mode is now the default (webdecoy.php:230). Everything is detected,
logged and reported; nothing is blocked, throttled or 403'd until the owner
turns blocking on. suppression_reason() is the single gate, returning
'disabled' | 'monitor' | 'proxy' | '', and every automatic block now routes
through enforce_block().

Fixes #48 — proxy IP collapse. behind_cloudflare defaults false and
trusted_proxies defaults empty with no auto-detection, so behind a reverse proxy
SignalCollector::getIP() correctly distrusts the spoofable forwarding headers and
returns REMOTE_ADDR — which is the proxy. Every visitor collapsed to one address,
and one hostile request could block them all for 24h. proxy_misconfigured() now
detects the mismatch and withholds all IP-keyed action while it holds.
getIP() is deliberately unchanged: trusting those headers would let any client
choose its own identity and frame a third party into the block table.

Fixes #49 — Blocker::block() validated only IP syntax. guard() now refuses
loopback, private/reserved space, a configured trusted-proxy range, and the
current request's proxy front door, and caps automatic CIDR blocks at /24 (v4)
and /48 (v6). Refusals are recorded and surfaced. New $force param bypasses the
guards and is set only where a human typed the address.

Fixes #50 — WEBDECOY_DISABLE recovery constant, one global monitor-mode switch
above the three existing dry-run flags, and admin notices that say plainly when
the plugin is watching rather than acting.

Fixes #51 — block_duration default 24h -> 1h (93% of hostile addresses are gone
within the hour). The honeypot path passed no duration at all, so it blocked
permanently. expires_at was written in UTC and compared against site-local time,
so on any site at UTC+1 or further east a short block was already expired when
written and blocking silently did nothing; the block table is now UTC end to end,
including the statistics page's MySQL NOW() comparison and the admin display.

Fixes #52 — WooCommerce counted completed orders as card testing. Three paid
sub-$5 orders in an hour, the normal profile for digital downloads and donations,
read as an attack. Successful orders no longer count toward card-testing patterns
or the checkout velocity limit, and the checkout path refuses the order and logs
rather than writing a sitewide IP block.

Refs WebDecoy/app#476 — the cross-site actor feed no longer writes to the block
table. Only 2 of 4,866 addresses were ever seen at more than one site, 82% of
feed entries were already a week stale and none were still active, so blocking on
it mostly hit whoever holds the address now. It is advisory intelligence, and
maybe_upgrade() removes the rows it previously wrote.

75/75 tests pass, phpcs clean, phpstan clean apart from one pre-existing issue in
class-webdecoy-updater.php which this change does not touch.
proxy_misconfigured() inferred "there is a proxy in front of us" from
X-Forwarded-For / CF-Connecting-IP / X-Real-IP on the CURRENT request. Those
headers are client-controlled, so on any install without a configured trusted
proxy — the default, and the correct configuration for an ordinary host — a
visitor could send one header and have suppression_reason() return 'proxy',
switching the entire enforcement stack off for their own request. That is
strictly worse than the bug the state was added to contain.

The signal is now a stored flag written only from an authenticated
administrator's request (maybe_flag_proxy() on admin_init, gated on
manage_options), which is a trustworthy sample of how traffic reaches the site
and cannot be forged by a visitor. Front-end code reads the flag; nothing on a
front-end path reads the headers.

Blocker::guard()'s proxy-front-door refusal had the same defect — an attacker
reaching the origin directly could send X-Forwarded-For and make guard() refuse
to block them. It now keys off the same stored flag.

The header list is also widened (True-Client-IP, Forwarded, X-Sucuri-ClientIP,
Fastly-Client-IP, Incap-Client-IP, X-Cluster-Client-IP). It now drives only the
admin-side detection, where a false positive costs a notice and a false negative
costs the backstop, so breadth is the safe direction.

Also gates the two WooCommerce is_blocked() checks on the suppression state and
removes the sitewide block writes from the Store API checkout path, which the
classic path had already lost.

Found by the adversarial review that was re-run after the first attempt lost 10
of 11 agents to a session limit. Thirteen further blockers from that review are
NOT fixed here and are filed separately; the release stays blocked.
@cport1

cport1 commented Jul 29, 2026

Copy link
Copy Markdown
Contributor Author

⛔ Do not merge. Do not release. The review has run and it found 14 blockers.

Converted to draft. The adversarial review that died on a session limit was re-run
(23/23 agents, all six lenses) and the result is not close to shippable.

Fixed on the branch (e10098f)

A complete enforcement bypass that this PR introduced. proxy_misconfigured() inferred
"there is a proxy in front of us" from X-Forwarded-For / CF-Connecting-IP / X-Real-IP
on the current request. Those are client-controlled, so on any install without a configured
trusted proxy — the default, and the correct config for an ordinary host — a visitor could
send one header and have suppression_reason() return 'proxy', switching the entire
enforcement stack off for their own request. Blocker::guard()'s front-door refusal had the
same defect. Both now key off a flag written only from an authenticated admin request.

That made the branch strictly less safe than the 2.3.0 it was meant to protect, so it could not
be left standing. Everything below is filed rather than fixed, because the last two rounds of
unreviewed hand-fixes are what produced these.

Blocking, filed

# P What
#56 P0 Monitor mode doesn't suppress the WooCommerce checkout refusals. Velocity / card-testing / bot-score and their Store API twins refuse orders with no gate. On an offline-payment store, 6 orders in a rolling hour kills the checkout — while the banner reads "nothing is blocked" and WEBDECOY_DISABLE, enabled=false and monitor mode all fail to stop it. Also catch_coupon() still writes a real sitewide block.
#57 P0 The Monitor mode checkbox reads the raw stored option, which has no monitor_mode key after upgrade, so it renders unchecked while the plugin is in monitor mode — and since an unchecked box submits nothing and sanitize_options() rebuilds from scratch, saving any setting silently turns monitor mode off.
#58 P0 check_login() returns a WP_Error on the authenticate filter with no suppression check and no WEBDECOY_DISABLE check. The kill switch this release documents as "you can always recover over FTP" cannot recover a login lockout.
#59 P1 serve_challenge_page() sends status_header(403) + exit() with no gate; only the sibling block branch is gated.
#60 P1 The new status <> 'success' filter excludes nothing on COD/BACS/Cheque stores — those gateways never call payment_complete(), so no row is ever marked success and every paid order still counts as a card-testing attempt. The #52 fix does not work on offline-payment stores.
#61 P1 track_attempt() inserts one row per checkout POST, not per order, so a customer retrying a declined card manufactures the multi-attempt evidence detect_card_testing() reads.
#62 P1 The actor feed is now a write-only dead store: intel_for() has zero callers anywhere. The migration removed the enforcement and replaced it with something nothing reads — the feature was deleted, not re-scoped.
#63 P2 Excluding THROTTLE from record_suppressed_action() makes the notice say "no request has met the bar for enforcement yet" on a default install where rate limiting is the main thing being suppressed.

The honest read

The individual fixes in this PR are mostly right. What is wrong is the premise that a single
gate in handle_rule_decision() covers enforcement
— the plugin acts on requests from at
least five other places (checkout classic, checkout Store API, coupon honeytoken, challenge page,
login filter), and none of them route through it. Until suppressed() is consulted at every one
of those points, the changelog's headline claim and the admin banner are both false, and a
merchant who upgrades can lose orders with no working off switch.

Recommended: fix #56, #57, #58 and #59 on this branch, re-run the review, and only then consider
wp.org. #60 and #61 are pre-existing WooCommerce defects that #52 revealed rather than caused —
they can ship after, but the changelog must stop claiming #52 is fixed for offline-payment stores.

…58 #59)

The release's premise was that one gate in handle_rule_decision() covered
enforcement. It did not: the plugin acts on requests from six other places, none
of which routed through it, so monitor mode and WEBDECOY_DISABLE were promises
the code did not keep.

#56 WooCommerce checkout, classic and Store API. The velocity, card-testing and
bot-score refusals aborted the order with no gate. An error notice in
woocommerce_checkout_process stops order creation, so on an offline-payment store
(COD/BACS/Cheque never call payment_complete(), so no attempt row is ever marked
success) six orders in a rolling hour killed the checkout — while the banner read
"nothing is blocked" and monitor mode, enabled=false and WEBDECOY_DISABLE all
failed to stop it. Detection and logging are now unconditional and only the
notice/RouteException is withheld. catch_coupon() was also writing a real
sitewide block from a honeytoken hit in monitor mode; rejecting the coupon is
kept, since a code that does not exist answering as though it does not is the
deception working, not enforcement.

#57 The monitor_mode default was merged in at runtime but never persisted, so the
settings form — which reads the stored array directly — rendered the checkbox
unchecked on every upgraded install. An unchecked box submits nothing and
sanitize_options() rebuilds from scratch, so saving ANY setting silently turned
full enforcement on. maybe_upgrade() now persists the key, the activator seeds it
for fresh installs, and the render site defaults to checked when it is absent.

#58 check_login() returned a WP_Error with no gate and no WEBDECOY_DISABLE check,
so the kill switch this release documents as "you can always recover over FTP"
could not recover a login lockout. Gated, along with filter_comment() (a literal
403, reachable at default sensitivity) and check_registration(). check_canary_login()
still refuses in monitor mode — a canary credential is zero-false-positive
evidence — but now honours WEBDECOY_DISABLE, because a kill switch that has
exceptions is not one.

#59 serve_challenge_page() sends a 403 interstitial and exit(); only the sibling
block branch was gated. Now gated after the verified-cookie check, so a visitor
who already solved the challenge is not counted as a withheld action.

Withheld actions feed record_suppressed_action(), so the monitor-mode counter
reflects what enforcement would have done rather than staying at zero.

75/75 tests, phpcs clean, phpstan clean apart from the pre-existing
class-webdecoy-updater.php issue.
@cport1
cport1 marked this pull request as ready for review July 29, 2026 22:42
@cport1
cport1 merged commit d94f5d9 into main Jul 29, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Behind a reverse proxy the plugin resolves every visitor to the proxy's address, so one anonymous tripwire hit 403s real visitors for 24 hours

1 participant