Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions changelog.txt
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
*** WebDecoy Bot Detection Changelog ***

= 2.3.1 - 2026-07-26 =
* Fixed: Forwarded detections carry the visitor's own request signature (`cs`). The plugin beacons detections over its own HTTP connection, so the ingest service was identifying visitors by THIS SITE's outbound request headers — identical on every beacon — which collapsed every visitor a site reported into one shared "actor". Header names only, plus Accept-Language and Accept-Encoding; no header values otherwise leave the site, and proxy/CDN-injected names (cf-*, x-forwarded-*) are excluded so a visitor fingerprints consistently wherever they are seen.

= 2.3.0 - 2026-07-23 =
* Added: One-click WebDecoy Cloud connect (Settings → WebDecoy Cloud). Clicking Connect opens app.webdecoy.com to approve; on return the plugin exchanges a one-time token server-side and stores the provisioned API keys (encrypted at rest). Manual API key entry remains available under "Advanced: manual configuration".
* Added: "Send me a monthly security report" opt-in at connect time.
Expand Down
5 changes: 4 additions & 1 deletion readme.txt
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ Donate link: https://webdecoy.com
Tags: security, bot detection, spam protection, woocommerce, firewall
Requires at least: 6.1
Tested up to: 7.0
Stable tag: 2.3.0
Stable tag: 2.3.1
Requires PHP: 7.4
License: GPLv2 or later
License URI: https://www.gnu.org/licenses/gpl-2.0.html
Expand Down Expand Up @@ -195,6 +195,9 @@ The bundled good-bot list (sdk/src/GoodBotList.php) stores a documentation URL f

== Changelog ==

= 2.3.1 =
* Fixed: Detections forwarded from your site are now identified by the visitor's own request signature. Previously they were identified by your server's outgoing connection, which is the same for every visitor — so every visitor a site reported was grouped into a single "actor" in the dashboard. Only request header NAMES plus Accept-Language and Accept-Encoding are sent; no header values leave your site.

= 2.3.0 =
* Added: One-click WebDecoy Cloud connect — approve on app.webdecoy.com and your API keys are provisioned automatically; manual key entry moved under "Advanced: manual configuration"
* Added: Optional monthly security report opt-in when connecting
Expand Down
28 changes: 28 additions & 0 deletions sdk/src/Detection.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ class Detection
private ?string $threatLevel = null;
private array $metadata = [];
private ?int $timestamp = null;
/** @var array Visitor fingerprint material for forwarded detections (`cs`). */
private array $clientSignals = [];

/**
* Create a Detection from an array of data
Expand Down Expand Up @@ -131,6 +133,14 @@ public function toApiPayload(string $organizationId): array
$payload['source'] = $this->source;
}

// The visitor's own fingerprint material. Required on any forwarded
// detection: the ingest service sees THIS SERVER's headers on the
// beacon, not the visitor's, and composing an identity from those gives
// every visitor a single shared one. See SignalCollector::getClientSignals.
if (!empty($this->clientSignals)) {
$payload['cs'] = $this->clientSignals;
}

// Include metadata (MITRE tactic info, etc.)
if (!empty($this->metadata)) {
$payload['metadata'] = $this->metadata;
Expand Down Expand Up @@ -296,6 +306,24 @@ public function setHoneypotValue(?string $honeypotValue): self
return $this;
}

public function getClientSignals(): array
{
return $this->clientSignals;
}

/**
* Set the visitor's fingerprint material, from
* SignalCollector::getClientSignals(). Required on any detection this
* server forwards on someone else's behalf — without it the ingest service
* identifies the visitor by THIS server's beacon headers, which are the
* same for every visitor.
*/
public function setClientSignals(array $clientSignals): self
{
$this->clientSignals = $clientSignals;
return $this;
}

public function getSource(): ?string
{
return $this->source;
Expand Down
63 changes: 63 additions & 0 deletions sdk/src/SignalCollector.php
Original file line number Diff line number Diff line change
Expand Up @@ -411,6 +411,69 @@ public function getAccept(): ?string
return $this->getServerVar('HTTP_ACCEPT');
}

/**
* Header-name prefixes added by a proxy or CDN rather than by the client.
*
* Excluded for the same reason the edge sensor excludes them: they are
* constant for every request through that infrastructure, so they carry no
* information, but including them would make the same visitor fingerprint
* differently here than anywhere else they are seen.
*
* @var string[]
*/
private const PROXY_HEADER_PREFIXES = [
'cf-',
'x-forwarded-',
'x-real-ip',
'true-client-ip',
'cdn-loop',
'x-vercel-',
'fastly-',
'x-amz-cf-',
];

/**
* Client fingerprint material for a detection forwarded to the ingest
* service (the `cs` payload field).
*
* This site's server is reporting somebody else's request, and it beacons
* over its own HTTP connection — so without this the ingest service has
* nothing but the beacon's own headers to identify the visitor with, and
* those are identical for every visitor this site ever reports. It grouped
* them all into one "actor" as a result.
*
* Header VALUES are not forwarded. The identity is composed from the SET of
* header names plus Accept-Language and Accept-Encoding, so those two are
* sent and every other header contributes its name alone — nothing here can
* carry a cookie or an Authorization header off this server.
*
* There is no TLS material: PHP is handed a decrypted request and never
* sees the ClientHello, so no JA3/JA4 is derivable. The header-name set
* carries the identity on its own, which is weaker but honest.
*
* @return array{hn: string[], al: string, ae: string}
*/
public function getClientSignals(): array
{
$names = [];
foreach (array_keys($this->getHeaders()) as $header) {
$name = strtolower((string) $header);
foreach (self::PROXY_HEADER_PREFIXES as $prefix) {
if (strpos($name, $prefix) === 0) {
continue 2;
}
}
$names[] = $name;
}
sort($names);

return [
'hn' => array_values(array_unique($names)),
'al' => $this->sanitizeString($this->getAcceptLanguage()),
'ae' => $this->sanitizeString($this->getAcceptEncoding()),
];
}

/**
* Get current page URL
*
Expand Down
151 changes: 151 additions & 0 deletions tests/ClientSignalsTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
<?php

declare(strict_types=1);

/**
* Tests for SignalCollector::getClientSignals — the visitor fingerprint material
* forwarded on a server-side detection (the `cs` payload field).
*
* This exists because of a real failure. The plugin beacons detections over its
* own HTTP connection, so the ingest service sees THIS SITE's request headers on
* the beacon rather than the visitor's, and it was composing the visitor's
* network identity from those. They are identical on every beacon a site sends,
* so every visitor a site ever reported collapsed into one shared "actor" — in
* production one such identity reached 500+ addresses and 88 unrelated user
* agents before anyone noticed.
*
* Two properties matter and are both pinned below: the material must actually
* distinguish visitors, and it must carry no header VALUES beyond the two the
* server's fingerprint reads. A plugin that shipped its visitors' cookies to us
* would be a far worse bug than the one it fixes.
*
* Run: php tests/run.php
*/

require_once dirname(__DIR__) . '/sdk/src/SignalCollector.php';

$t = ['TestRunner', 'test'];
$same = ['TestRunner', 'assertSame'];
$true = ['TestRunner', 'assertTrue'];

/** Collect signals for a given $_SERVER, restoring it afterwards. */
function wd_signals_for(array $server): array
{
$saved = $_SERVER;
$_SERVER = $server;
try {
return (new \WebDecoy\SignalCollector())->getClientSignals();
} finally {
$_SERVER = $saved;
}
}

echo "\nClient signals: what is forwarded\n";

$t('header names are lowercased and sorted', function () use ($same) {
$cs = wd_signals_for([
'HTTP_USER_AGENT' => 'Mozilla/5.0 (compatible; Googlebot/2.1)',
'HTTP_ACCEPT' => '*/*',
'HTTP_FROM' => 'googlebot(at)googlebot.com',
]);
$same(['accept', 'from', 'user-agent'], $cs['hn'], 'names normalized and ordered');
});

$t('the two values the network identity reads are sent', function () use ($same) {
$cs = wd_signals_for([
'HTTP_ACCEPT_LANGUAGE' => 'en-US,en;q=0.9',
'HTTP_ACCEPT_ENCODING' => 'gzip, br',
]);
$same('en-US,en;q=0.9', $cs['al'], 'accept-language value');
$same('gzip, br', $cs['ae'], 'accept-encoding value');
});

$t('absent values are empty strings, not missing keys', function () use ($same) {
$cs = wd_signals_for(['HTTP_USER_AGENT' => 'curl/8.4.0']);
$same('', $cs['al'], 'accept-language absent');
$same('', $cs['ae'], 'accept-encoding absent');
});

echo "\nClient signals: what is NOT forwarded\n";

$t('no header values leave the site beyond those two', function () use ($true) {
$cs = wd_signals_for([
'HTTP_USER_AGENT' => 'Mozilla/5.0',
'HTTP_COOKIE' => 'wordpress_logged_in_abc=admin|SECRET-SESSION-TOKEN',
'HTTP_AUTHORIZATION' => 'Bearer SK-LIVE-PRIVATE',
'HTTP_X_WP_NONCE' => 'PRIVATE-NONCE',
]);
// Serialize the whole payload so this catches a value smuggled in under any
// key, not only the ones this test thought to look at.
$wire = json_encode($cs);
$true(strpos($wire, 'SECRET-SESSION-TOKEN') === false, 'cookie value not forwarded');
$true(strpos($wire, 'SK-LIVE-PRIVATE') === false, 'authorization value not forwarded');
$true(strpos($wire, 'PRIVATE-NONCE') === false, 'nonce value not forwarded');
});

$t('sensitive header NAMES are kept — the name set is the entropy', function () use ($true) {
$cs = wd_signals_for([
'HTTP_USER_AGENT' => 'Mozilla/5.0',
'HTTP_COOKIE' => 'a=b',
'HTTP_AUTHORIZATION' => 'Bearer x',
]);
$true(in_array('cookie', $cs['hn'], true), 'cookie name retained');
$true(in_array('authorization', $cs['hn'], true), 'authorization name retained');
});

$t('proxy and CDN header names are excluded', function () use ($same) {
// Constant for every request through that infrastructure, so they carry no
// information — but including them would make the same visitor fingerprint
// differently here than at the edge, splitting one actor in two.
$cs = wd_signals_for([
'HTTP_USER_AGENT' => 'curl/8.4.0',
'HTTP_CF_CONNECTING_IP' => '66.249.66.1',
'HTTP_CF_RAY' => '8f2a-IAD',
'HTTP_X_FORWARDED_FOR' => '66.249.66.1',
'HTTP_X_FORWARDED_PROTO' => 'https',
'HTTP_TRUE_CLIENT_IP' => '66.249.66.1',
'HTTP_CDN_LOOP' => 'cloudflare',
'HTTP_X_VERCEL_ID' => 'iad1',
'REMOTE_ADDR' => '10.0.0.1',
]);
$same(['user-agent'], $cs['hn'], 'only the visitor\'s own header names remain');
});

echo "\nClient signals: does it actually distinguish visitors\n";

$t('two different clients produce different material', function () use ($true) {
$googlebot = wd_signals_for([
'HTTP_USER_AGENT' => 'Mozilla/5.0 (compatible; Googlebot/2.1)',
'HTTP_ACCEPT' => '*/*',
'HTTP_FROM' => 'googlebot(at)googlebot.com',
'HTTP_ACCEPT_ENCODING' => 'gzip, deflate, br',
]);
$amazonbot = wd_signals_for([
'HTTP_USER_AGENT' => 'Mozilla/5.0 (compatible; Amazonbot/0.1)',
'HTTP_ACCEPT' => '*/*',
'HTTP_ACCEPT_LANGUAGE' => 'en-US,en;q=0.5',
'HTTP_ACCEPT_ENCODING' => 'gzip',
]);
$true($googlebot !== $amazonbot, 'distinct clients must not share fingerprint material');
});

$t('the same client is stable across requests', function () use ($same) {
$server = [
'HTTP_USER_AGENT' => 'Mozilla/5.0 (compatible; Googlebot/2.1)',
'HTTP_ACCEPT' => '*/*',
'HTTP_ACCEPT_ENCODING' => 'gzip',
];
$same(wd_signals_for($server), wd_signals_for($server), 'identical requests fingerprint alike');
});

echo "\nClient signals: the wire contract\n";

$t('keys match what the ingest service reads', function () use ($same, $true) {
// Matched by name across a repository boundary — the Go side decodes `hn`,
// `al` and `ae`, so a rename here is silent data loss, not a build error.
$cs = wd_signals_for(['HTTP_USER_AGENT' => 'curl/8.4.0']);
$keys = array_keys($cs);
sort($keys);
$same(['ae', 'al', 'hn'], $keys, 'wire keys are hn/al/ae');
$true(is_array($cs['hn']), 'hn is a list');
});
40 changes: 38 additions & 2 deletions webdecoy.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
* Plugin Name: WebDecoy Bot Detection
* Plugin URI: https://webdecoy.com/wordpress
* Description: Protect your WordPress site from bots, spam, and carding attacks with WebDecoy's advanced threat detection.
* Version: 2.3.0
* Version: 2.3.1
* Requires at least: 6.1
* Requires PHP: 7.4
* Author: WebDecoy
Expand Down Expand Up @@ -41,7 +41,7 @@ function str_starts_with(string $haystack, string $needle): bool
}

// Plugin constants
define('WEBDECOY_VERSION', '2.3.0');
define('WEBDECOY_VERSION', '2.3.1');
define('WEBDECOY_PLUGIN_FILE', __FILE__);
define('WEBDECOY_PLUGIN_DIR', plugin_dir_path(__FILE__));
define('WEBDECOY_PLUGIN_URL', plugin_dir_url(__FILE__));
Expand Down Expand Up @@ -2348,6 +2348,16 @@ private function forward_to_ingest(array $detection, string $ip): void
// Add server-side data
'ip' => $ip,
'source' => 'wordpress_plugin',
// The visitor's own fingerprint material.
//
// This is a server-to-server beacon, so the ingest service sees THIS
// site's outbound request headers on it, not the visitor's. It used
// to identify visitors from those — identical on every beacon this
// site sends — which collapsed every visitor we ever reported into a
// single shared "actor". Header names only, plus Accept-Language and
// Accept-Encoding; no header values otherwise, so nothing sensitive
// leaves the site.
'cs' => $this->build_client_signals(),
];

// Send to ingest (fire-and-forget, don't block)
Expand All @@ -2363,6 +2373,32 @@ private function forward_to_ingest(array $detection, string $ip): void
]);
}

/**
* The visitor's fingerprint material for a forwarded detection (`cs`).
*
* Delegates to the SDK's SignalCollector, which is the canonical
* implementation and is shared with any other host framework. Falls back to
* an empty array if the SDK is unavailable for any reason — the ingest
* service treats absent client signals as "compose no network identity",
* which is correct: no actor is better than one shared by every visitor.
*
* @return array
*/
private function build_client_signals(): array
{
if (!class_exists('\\WebDecoy\\SignalCollector')) {
return [];
}

try {
$collector = new \WebDecoy\SignalCollector();
return $collector->getClientSignals();
} catch (\Throwable $e) {
// A detection beacon is never worth breaking the page over.
return [];
}
}

/**
* AJAX: Test API connection
*/
Expand Down
Loading