diff --git a/changelog.txt b/changelog.txt index a4c293f..c5b4e54 100644 --- a/changelog.txt +++ b/changelog.txt @@ -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. diff --git a/readme.txt b/readme.txt index 6c45f6d..a656764 100644 --- a/readme.txt +++ b/readme.txt @@ -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 @@ -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 diff --git a/sdk/src/Detection.php b/sdk/src/Detection.php index 7616bca..5c4f4d0 100644 --- a/sdk/src/Detection.php +++ b/sdk/src/Detection.php @@ -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 @@ -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; @@ -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; diff --git a/sdk/src/SignalCollector.php b/sdk/src/SignalCollector.php index 7ad253e..124d007 100644 --- a/sdk/src/SignalCollector.php +++ b/sdk/src/SignalCollector.php @@ -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 * diff --git a/tests/ClientSignalsTest.php b/tests/ClientSignalsTest.php new file mode 100644 index 0000000..df25239 --- /dev/null +++ b/tests/ClientSignalsTest.php @@ -0,0 +1,151 @@ +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'); +}); diff --git a/webdecoy.php b/webdecoy.php index 2b49102..2595593 100644 --- a/webdecoy.php +++ b/webdecoy.php @@ -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 @@ -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__)); @@ -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) @@ -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 */