From 67497a9b4322523c99637940e18adcb5d63962d1 Mon Sep 17 00:00:00 2001 From: Chris Portscheller Date: Sun, 26 Jul 2026 13:16:39 -0500 Subject: [PATCH] fix: forward the visitor's own request signature on server-side detections MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The plugin beacons detections to the ingest service over its own HTTP connection, so the request that arrives there carries THIS SITE's outbound headers, not the visitor's. The service was composing the visitor's network identity from those — byte-identical on every beacon a given site sends — so every visitor a site ever reported was folded into one shared "actor". The platform side of this shipped first and fails safe: a forwarded detection with no visitor signature now composes no network identity at all, on the grounds that no actor beats a false one. Correct, but it means WordPress detections stopped correlating entirely. This restores them. SignalCollector::getClientSignals() builds the material and Detection carries it as `cs`. Header NAMES only, plus Accept-Language and Accept-Encoding, which are the two values the server's identity actually reads — so a visitor's cookies and Authorization header never leave the site, while the name set that carries the entropy is preserved intact. Proxy and CDN-injected names (cf-*, x-forwarded-*, true-client-ip) are excluded so the same visitor fingerprints consistently whether seen here or at a customer's edge. 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 alone — weaker than what an edge sensor can produce, and honest about it. Tested for both properties that matter: that the material actually distinguishes two different crawlers and is stable for one, and that no header value beyond those two can leave the site — asserted by serializing the whole payload rather than checking the keys this test happened to think of. --- changelog.txt | 3 + readme.txt | 5 +- sdk/src/Detection.php | 28 +++++++ sdk/src/SignalCollector.php | 63 +++++++++++++++ tests/ClientSignalsTest.php | 151 ++++++++++++++++++++++++++++++++++++ webdecoy.php | 40 +++++++++- 6 files changed, 287 insertions(+), 3 deletions(-) create mode 100644 tests/ClientSignalsTest.php 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 */