From bb44718988db7b14f70ea0d5f8d0686e84894b2c Mon Sep 17 00:00:00 2001 From: Chris Portscheller Date: Tue, 18 Aug 2026 12:10:51 -0500 Subject: [PATCH] feat: reserved WebDecoy-Test User-Agent always records a test detection curl -A "WebDecoy-Test/1.0" https://your-site.example/ now always produces a detection (WebDecoy/app#677): logged locally on every install, submitted to the cloud on connected ones, and answered with a 403 JSON receipt so the curl output itself shows the plugin acted. Checked before the allowlist (a developer testing from an allowlisted IP still gets their receipt), before the block check, and before rules: a test must always fire and must never trip enforcement or the critical-moment alert. The cloud labels these rows as tests and keeps them out of stats and billing. --- webdecoy.php | 71 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/webdecoy.php b/webdecoy.php index 9f23797..a9923e2 100644 --- a/webdecoy.php +++ b/webdecoy.php @@ -1114,6 +1114,18 @@ public function early_check(): void $blocker = new WebDecoy_Blocker(); $ip = $this->get_client_ip(); + // Reserved test trigger (WebDecoy/app#677): + // curl -A "WebDecoy-Test/1.0" https://your-site.example/ + // always records a detection, so a fresh install can prove itself end + // to end. Deliberately checked before the allowlist (a developer + // testing from an allowlisted IP still gets their receipt), before the + // block check, and before rules (a test must never trip enforcement). + // The cloud labels these rows as tests and keeps them out of stats. + if ($this->is_test_trigger_request()) { + $this->handle_test_trigger($ip); + return; + } + // Allowlisted IPs bypass all detection entirely. if ($blocker->is_allowlisted($ip)) { return; @@ -1911,6 +1923,65 @@ private function submit_detection(\WebDecoy\DetectionResult $result, string $ip) $client->submitDetection($detection); } + /** + * Whether this request carries the reserved test User-Agent + * (prefix "WebDecoy-Test/", case-insensitive). See WebDecoy/app#677. + */ + private function is_test_trigger_request(): bool + { + if (!isset($_SERVER['HTTP_USER_AGENT'])) { + return false; + } + $ua = ltrim(sanitize_text_field(wp_unslash($_SERVER['HTTP_USER_AGENT']))); + return stripos($ua, 'WebDecoy-Test/') === 0; + } + + /** + * Record the reserved test detection and answer with an unambiguous + * receipt (WebDecoy/app#677). + * + * Logs locally always, submits to the cloud when connected — the same two + * paths a real detection takes — then responds 403 so the curl output + * itself shows the plugin acted. Never blocks the IP, never trips rules: + * a test must have no consequences beyond its own row. + * + * @param string $ip + */ + private function handle_test_trigger(string $ip): void + { + global $wpdb; + + $result = new \WebDecoy\DetectionResult(100, ['test_trigger']); + + // Local log, inlined rather than via log_detection(): that path also + // queues the critical-moment alert for CRITICAL rows, and a test must + // not page anyone. + $wpdb->insert($wpdb->prefix . 'webdecoy_detections', [ + 'ip_address' => $ip, + 'user_agent' => isset($_SERVER['HTTP_USER_AGENT']) ? sanitize_text_field(wp_unslash($_SERVER['HTTP_USER_AGENT'])) : '', + 'score' => $result->getScore(), + 'threat_level' => $result->getThreatLevel(), + 'source' => 'wordpress_plugin', + 'flags' => json_encode(['flags' => $result->getFlags(), 'metadata' => ['test' => true]]), + 'created_at' => gmdate('Y-m-d H:i:s'), + ]); + + try { + $this->submit_detection($result, $ip); + } catch (\Exception $e) { + error_log('WebDecoy API error: ' . $e->getMessage()); + } + + nocache_headers(); + status_header(403); + header('Content-Type: application/json; charset=utf-8'); + echo wp_json_encode([ + 'webdecoy_test' => true, + 'message' => 'Test detection recorded. Check your WebDecoy dashboard.', + ]); + exit; + } + /** * Check comment submission *