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
28 changes: 28 additions & 0 deletions admin/partials/settings-page.php
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,34 @@
</td>
</tr>
</table>

<h3><?php esc_html_e('Honeytoken', 'webdecoy'); ?></h3>
<p class="description">
<?php esc_html_e('Automatically plants an invisible decoy link on your pages, pointing at a secret path only a link-following scraper would ever request. A real visitor never sees it (offscreen, hidden from screen readers, marked nofollow). A hit is armed as a tripwire — deterministic, zero false positives.', 'webdecoy'); ?>
</p>

<table class="form-table">
<tr>
<th scope="row"><?php esc_html_e('Enable Honeytoken', 'webdecoy'); ?></th>
<td>
<label>
<input type="checkbox" name="webdecoy_options[honeytoken_enabled]" value="1"
<?php checked($options['honeytoken_enabled'] ?? true); ?> />
<?php esc_html_e('Inject the hidden decoy link and enforce its path', 'webdecoy'); ?>
</label>
</td>
</tr>
<tr>
<th scope="row"><?php esc_html_e('Daily Rotation', 'webdecoy'); ?></th>
<td>
<label>
<input type="checkbox" name="webdecoy_options[honeytoken_rotate]" value="1"
<?php checked($options['honeytoken_rotate'] ?? false); ?> />
<?php esc_html_e('Rotate the decoy path daily (yesterday\'s stays armed briefly so an in-progress crawl still trips)', 'webdecoy'); ?>
</label>
</td>
</tr>
</table>
</div>

<!-- Good Bots Tab -->
Expand Down
1 change: 1 addition & 0 deletions changelog.txt
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
* Added: Clearance client — bundled @webdecoy/client browser script that silently mints a wd_clearance cookie for real visitors (idle-deferred, once per session, no proof-of-work). Enables tripwire/decoy hits to durably lock out the offending device. Configured via a new publishable Site Key in the WebDecoy Cloud tab.
* Added: Rule engine — deterministic rules evaluated before heuristic scoring; first DENY/THROTTLE wins, with dry-run (log without blocking). Parity with @webdecoy/node.
* Added: Tripwires (deception layer) — deterministic, zero-false-positive blocking of hidden honeypot paths (scanner-bait like /.env, /.git/config, /wp-config.php). On by default. Custom exact paths, prefixes, and regex patterns; block or throttle; dry-run. New Settings → Tripwires tab.
* Added: Honeytoken — automatically injects an invisible decoy link on front-end pages pointing at a secret per-site path; only link-following scrapers ever request it, and a hit is armed as a tripwire. On by default, with optional daily rotation. Never shown to real visitors or logged-in users.
* Added: wd_clearance forwarding — a tripwire hit carrying the visitor's wd_clearance cookie is reported so the WebDecoy Cloud can durably deny the actor's device fingerprint (rotation-proof lockout). Heuristic rules never carry the token.
* Added: Violation reporting — rule hits are batched and reported to the WebDecoy Cloud (premium) on request shutdown, fire-and-forget with no added page latency. Hits are always recorded locally in the Detections page.
* Added: JS execution verification — detects non-JS HTTP scrapers (e.g., Scrapling Fetcher, curl_cffi)
Expand Down
130 changes: 130 additions & 0 deletions includes/class-webdecoy-honeytoken.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
<?php

declare(strict_types=1);

if (!defined('ABSPATH')) {
exit;
}

/**
* Honeytoken (F4 deception layer) — automatic sitewide hidden-link injection.
*
* Plants a visually-hidden, non-followable link on front-end pages pointing at a
* secret per-site path (`/__wd/{token}`). A real visitor never sees or clicks it
* (offscreen, aria-hidden, tabindex -1, nofollow/noindex); only a client that
* parses the HTML and follows links — a scraper — requests the path. The path is
* armed as a tripwire, so a hit is a deterministic, zero-false-positive
* automated-intent signal.
*
* WordPress owns page rendering, so injection is automatic — unlike @webdecoy/node
* where the developer must embed the link by hand.
*
* The token is derived by HMAC from a stored per-site secret, so it is
* unguessable and needs no extra storage. With rotation enabled it changes daily
* (yesterday's token stays armed as a grace window so a crawler mid-crawl still
* trips).
*
* Deliberately no robots.txt Disallow entry: a `Disallow: /__wd/` line would
* advertise the trap, and robots-honoring good bots never follow a nofollow
* hidden link anyway.
*/
class WebDecoy_Honeytoken
{
/** Base path for honeytoken tripwires (mirrors @webdecoy/node's default). */
private const BASE_PATH = '/__wd';

/** Token length (hex chars), matching node's randomBytes(6).toString('hex'). */
private const TOKEN_LEN = 12;

/** @var bool */
private $rotate;

public function __construct(bool $rotate = false)
{
$this->rotate = $rotate;
}

/**
* Get (or lazily create) the per-site secret the tokens are derived from.
*/
private function secret(): string
{
$secret = get_option('webdecoy_honeytoken_secret', '');
if (!is_string($secret) || $secret === '') {
$secret = bin2hex(random_bytes(16));
// Autoload so it's cheap to read on every request.
add_option('webdecoy_honeytoken_secret', $secret, '', 'yes');
}
return $secret;
}

/**
* Derive a token from the secret for a given label.
*/
private function token(string $label): string
{
return substr(hash_hmac('sha256', $label, $this->secret()), 0, self::TOKEN_LEN);
}

/**
* The path advertised in the injected link (today's, or the stable one).
*/
public function primary_path(): string
{
if ($this->rotate) {
return self::BASE_PATH . '/' . $this->token('day:' . gmdate('Y-m-d'));
}
return self::BASE_PATH . '/' . $this->token('stable');
}

/**
* All paths that should be armed as tripwires right now. With rotation this
* is today + yesterday (grace window); otherwise just the stable path.
*
* @return string[]
*/
public function active_paths(): array
{
if (!$this->rotate) {
return [self::BASE_PATH . '/' . $this->token('stable')];
}

$today = self::BASE_PATH . '/' . $this->token('day:' . gmdate('Y-m-d'));
$yesterday = self::BASE_PATH . '/' . $this->token('day:' . gmdate('Y-m-d', time() - DAY_IN_SECONDS));

return array_values(array_unique([$today, $yesterday]));
}

/**
* The hidden decoy link HTML. Byte-for-byte the same hiding technique as
* @webdecoy/node's honeytoken() so behavior matches across SDKs.
*/
public function render_link(): string
{
$path = esc_attr($this->primary_path());
return '<a href="' . $path . '" aria-hidden="true" tabindex="-1" rel="nofollow noindex" '
. 'style="position:absolute;left:-9999px;top:auto;width:1px;height:1px;overflow:hidden">.</a>';
}

/**
* Whether the honeytoken link should be injected on the current request.
* Skips logged-in users, feeds, and non-HTML contexts so a genuine visitor
* or authenticated session can never trip it.
*/
public function should_inject(): bool
{
if (is_admin() || wp_doing_ajax() || is_feed()) {
return false;
}
if (defined('REST_REQUEST') && REST_REQUEST) {
return false;
}
if (defined('DOING_CRON') && DOING_CRON) {
return false;
}
if (is_user_logged_in()) {
return false;
}
return true;
}
}
104 changes: 104 additions & 0 deletions tests/HoneytokenTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
<?php

declare(strict_types=1);

/**
* Tests for WebDecoy_Honeytoken (F4 hidden-link injection).
*
* Self-contained: defines the handful of WordPress functions the class touches
* (guarded so they don't collide with other tests or a real WP runtime), then
* exercises token derivation, the hidden-link markup, rotation, and that a
* honeytoken path actually trips a tripwire. Run: php tests/run.php
*/

use WebDecoy\Rules\RuleContext;
use WebDecoy\Rules\RuleEngine;
use WebDecoy\Rules\RuleResult;
use WebDecoy\Rules\TripwireRule;

if (!defined('ABSPATH')) {
define('ABSPATH', '/tmp/');
}
if (!defined('DAY_IN_SECONDS')) {
define('DAY_IN_SECONDS', 86400);
}
if (!isset($GLOBALS['__wd_opts'])) {
$GLOBALS['__wd_opts'] = [];
}
if (!function_exists('get_option')) {
function get_option($k, $d = false)
{
return $GLOBALS['__wd_opts'][$k] ?? $d;
}
}
if (!function_exists('add_option')) {
function add_option($k, $v, $a = '', $b = 'yes')
{
$GLOBALS['__wd_opts'][$k] = $v;
return true;
}
}
if (!function_exists('esc_attr')) {
function esc_attr($s)
{
return htmlspecialchars((string) $s, ENT_QUOTES);
}
}

require_once dirname(__DIR__) . '/includes/class-webdecoy-honeytoken.php';

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

echo "\nWebDecoy_Honeytoken\n";

$t('derives a stable /__wd/{12-hex} path from the per-site secret', function () use ($eq, $true) {
$h = new WebDecoy_Honeytoken(false);
$p = $h->primary_path();
$true(strpos($p, '/__wd/') === 0, 'under /__wd/');
$eq(strlen('/__wd/') + 12, strlen($p), '12-hex token');
$eq($p, $h->primary_path(), 'deterministic across calls');
$eq($p, (new WebDecoy_Honeytoken(false))->primary_path(), 'stable across instances (same secret)');
});

$t('stable mode arms exactly the advertised path', function () use ($eq) {
$h = new WebDecoy_Honeytoken(false);
$paths = $h->active_paths();
$eq(1, count($paths));
$eq($h->primary_path(), $paths[0]);
});

$t('persists an unguessable secret to options', function () use ($true) {
(new WebDecoy_Honeytoken(false))->primary_path();
$secret = $GLOBALS['__wd_opts']['webdecoy_honeytoken_secret'] ?? '';
$true(is_string($secret) && strlen($secret) >= 16, 'secret stored');
});

$t('hidden link matches the node hiding technique', function () use ($true) {
$h = new WebDecoy_Honeytoken(false);
$link = $h->render_link();
$true(strpos($link, 'href="' . $h->primary_path() . '"') !== false, 'href = primary path');
$true(strpos($link, 'aria-hidden="true"') !== false, 'aria-hidden');
$true(strpos($link, 'tabindex="-1"') !== false, 'tabindex -1');
$true(strpos($link, 'rel="nofollow noindex"') !== false, 'nofollow noindex');
$true(strpos($link, 'position:absolute;left:-9999px') !== false, 'offscreen');
});

$t('rotation arms today + yesterday and differs from stable', function () use ($eq, $true) {
$r = new WebDecoy_Honeytoken(true);
$paths = $r->active_paths();
$eq(2, count($paths), 'today + yesterday grace window');
$true(in_array($r->primary_path(), $paths, true), 'today is armed');
$true($r->primary_path() !== (new WebDecoy_Honeytoken(false))->primary_path(), 'rotating != stable');
});

$t('a honeytoken path trips a tripwire; normal pages pass', function () use ($eq) {
$h = new WebDecoy_Honeytoken(false);
$engine = new RuleEngine([new TripwireRule(['paths' => $h->active_paths(), 'includeDefaults' => false])]);
$hit = $engine->evaluate(new RuleContext('9.9.9.9', $h->primary_path(), 'GET', 'scrapy', [], 1700000000000));
$eq(RuleResult::DENY, $hit->action);
$eq('tripwire', $hit->rule);
$miss = $engine->evaluate(new RuleContext('9.9.9.9', '/', 'GET', 'human', [], 1700000000000));
$eq(RuleResult::ALLOW, $miss->action);
});
59 changes: 55 additions & 4 deletions webdecoy.php
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,12 @@
'tripwire_action' => 'block', // block | throttle
'tripwire_dry_run' => false, // record violations without blocking

// Honeytoken: auto-inject a hidden decoy link on front-end pages and
// arm its secret path as a tripwire. Only link-following scrapers
// ever hit it — deterministic, zero false positives. On by default.
'honeytoken_enabled' => true,
'honeytoken_rotate' => false, // rotate the token daily (with grace)

// Form Protection
'protect_comments' => true,
'protect_login' => true,
Expand Down Expand Up @@ -575,6 +581,12 @@
add_action('wp_enqueue_scripts', [$this, 'enqueue_clearance_client']);
}

// Honeytoken: inject the hidden decoy link on front-end pages. The path
// itself is armed as a tripwire in build_rule_engine().
if (!empty($this->options['honeytoken_enabled']) && !is_admin()) {
add_action('wp_footer', [$this, 'inject_honeytoken_link'], 99);
}

// JS execution verification: inject challenge token meta tag and report page serve
// Only active when scanner is enabled and API key is configured (premium)
if ($this->options['scanner_enabled'] && !is_admin() && $this->is_premium()) {
Expand Down Expand Up @@ -647,6 +659,7 @@
require_once WEBDECOY_PLUGIN_DIR . 'includes/class-webdecoy-pow.php';
require_once WEBDECOY_PLUGIN_DIR . 'includes/class-webdecoy-behavioral-scorer.php';
require_once WEBDECOY_PLUGIN_DIR . 'includes/class-webdecoy-violation-reporter.php';
require_once WEBDECOY_PLUGIN_DIR . 'includes/class-webdecoy-honeytoken.php';

if (class_exists('WooCommerce')) {
require_once WEBDECOY_PLUGIN_DIR . 'includes/class-webdecoy-woocommerce.php';
Expand Down Expand Up @@ -788,16 +801,32 @@
{
$rules = [];

$action = ($this->options['tripwire_action'] ?? 'block') === 'throttle'
? \WebDecoy\Rules\RuleResult::THROTTLE
: \WebDecoy\Rules\RuleResult::DENY;
$dryRun = !empty($this->options['tripwire_dry_run']);

if (!empty($this->options['tripwire_enabled'])) {
$rules[] = new \WebDecoy\Rules\TripwireRule([
'paths' => is_array($this->options['tripwire_paths'] ?? null) ? $this->options['tripwire_paths'] : [],
'prefixes' => is_array($this->options['tripwire_prefixes'] ?? null) ? $this->options['tripwire_prefixes'] : [],
'patterns' => is_array($this->options['tripwire_patterns'] ?? null) ? $this->options['tripwire_patterns'] : [],
'includeDefaults' => !empty($this->options['tripwire_include_defaults']),
'action' => ($this->options['tripwire_action'] ?? 'block') === 'throttle'
? \WebDecoy\Rules\RuleResult::THROTTLE
: \WebDecoy\Rules\RuleResult::DENY,
'dryRun' => !empty($this->options['tripwire_dry_run']),
'action' => $action,
'dryRun' => $dryRun,
]);
}

// Arm the honeytoken path(s) as a tripwire. Independent of the general
// tripwire toggle: if honeytokens are on, their secret path is always
// enforced (the hidden link is only useful if a hit actually trips).
if (!empty($this->options['honeytoken_enabled'])) {
$honeytoken = new WebDecoy_Honeytoken(!empty($this->options['honeytoken_rotate']));
$rules[] = new \WebDecoy\Rules\TripwireRule([
'paths' => $honeytoken->active_paths(),
'includeDefaults' => false,
'action' => $action,
'dryRun' => $dryRun,
]);
}

Expand All @@ -808,6 +837,26 @@
return new \WebDecoy\Rules\RuleEngine($rules);
}

/**
* Inject the hidden honeytoken decoy link into the page footer. Only
* link-following scrapers ever request the path it points at.
*/
public function inject_honeytoken_link(): void
{
if (empty($this->options['honeytoken_enabled'])) {
return;
}

$honeytoken = new WebDecoy_Honeytoken(!empty($this->options['honeytoken_rotate']));
if (!$honeytoken->should_inject()) {
return;
}

// The link markup is a fixed, safe template (the path is esc_attr'd
// inside render_link()); emit it verbatim.
echo $honeytoken->render_link(); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
}

/**
* Build the rule context for the current request (trusted-proxy-resolved IP,
* path, method, UA, and headers — the Cookie header carries wd_clearance).
Expand All @@ -829,7 +878,7 @@
// intact for forwarding.
$headers = [];
if (isset($_SERVER['HTTP_COOKIE'])) {
$headers['cookie'] = (string) wp_unslash($_SERVER['HTTP_COOKIE']);

Check failure on line 881 in webdecoy.php

View workflow job for this annotation

GitHub Actions / PHP Coding Standards

Detected usage of a non-sanitized input variable: $_SERVER['HTTP_COOKIE']
}

return new \WebDecoy\Rules\RuleContext(
Expand Down Expand Up @@ -903,7 +952,7 @@
),
];

$wpdb->insert($wpdb->prefix . 'webdecoy_detections', [

Check warning on line 955 in webdecoy.php

View workflow job for this annotation

GitHub Actions / PHP Coding Standards

Use of a direct database call is discouraged.
'ip_address' => $violation->ip,
'user_agent' => $violation->userAgent ?? '',
'score' => $confidence,
Expand Down Expand Up @@ -967,7 +1016,7 @@
*/
private function is_challenge_verified(string $ip): bool
{
$cookie = isset($_COOKIE['webdecoy_verified']) ? sanitize_text_field($_COOKIE['webdecoy_verified']) : '';

Check failure on line 1019 in webdecoy.php

View workflow job for this annotation

GitHub Actions / PHP Coding Standards

$_COOKIE['webdecoy_verified'] not unslashed before sanitization. Use wp_unslash() or similar
if (empty($cookie)) {
return false;
}
Expand Down Expand Up @@ -1063,9 +1112,9 @@
'metadata' => $result->getMetadata(),
];

$wpdb->insert($table, [

Check warning on line 1115 in webdecoy.php

View workflow job for this annotation

GitHub Actions / PHP Coding Standards

Use of a direct database call is discouraged.
'ip_address' => $ip,
'user_agent' => isset($_SERVER['HTTP_USER_AGENT']) ? sanitize_text_field($_SERVER['HTTP_USER_AGENT']) : '',

Check failure on line 1117 in webdecoy.php

View workflow job for this annotation

GitHub Actions / PHP Coding Standards

$_SERVER['HTTP_USER_AGENT'] not unslashed before sanitization. Use wp_unslash() or similar
'score' => $result->getScore(),
'threat_level' => $result->getThreatLevel(),
'source' => 'wordpress_plugin',
Expand Down Expand Up @@ -1245,7 +1294,7 @@
{
$honeypot_name = 'webdecoy_hp_' . $context;

if (isset($_POST[$honeypot_name]) && !empty($_POST[$honeypot_name])) {

Check failure on line 1297 in webdecoy.php

View workflow job for this annotation

GitHub Actions / PHP Coding Standards

Processing form data without nonce verification.

Check failure on line 1297 in webdecoy.php

View workflow job for this annotation

GitHub Actions / PHP Coding Standards

Processing form data without nonce verification.
// Honeypot triggered - definitely a bot
$ip = $this->get_client_ip();
$blocker = new WebDecoy_Blocker();
Expand Down Expand Up @@ -1411,6 +1460,8 @@
$sanitized['tripwire_patterns'] = $this->sanitize_pattern_list($input['tripwire_patterns'] ?? '');
$sanitized['tripwire_action'] = in_array($input['tripwire_action'] ?? 'block', ['block', 'throttle'], true) ? $input['tripwire_action'] : 'block';
$sanitized['tripwire_dry_run'] = !empty($input['tripwire_dry_run']);
$sanitized['honeytoken_enabled'] = !empty($input['honeytoken_enabled']);
$sanitized['honeytoken_rotate'] = !empty($input['honeytoken_rotate']);

// Form Protection
$sanitized['protect_comments'] = !empty($input['protect_comments']);
Expand Down Expand Up @@ -1694,7 +1745,7 @@
}

// Get detection data
$detection_json = isset($_POST['detection']) ? wp_unslash($_POST['detection']) : '';

Check failure on line 1748 in webdecoy.php

View workflow job for this annotation

GitHub Actions / PHP Coding Standards

Detected usage of a non-sanitized input variable: $_POST['detection']
$detection = json_decode($detection_json, true);

if (!$detection || !is_array($detection)) {
Expand Down Expand Up @@ -1789,7 +1840,7 @@
$threat_level = 'LOW';
}

$wpdb->insert($table, [

Check warning on line 1843 in webdecoy.php

View workflow job for this annotation

GitHub Actions / PHP Coding Standards

Use of a direct database call is discouraged.
'ip_address' => $ip,
'user_agent' => $user_agent,
'score' => $score,
Expand Down Expand Up @@ -1866,7 +1917,7 @@
return;
}

$api_key = sanitize_text_field($_POST['api_key'] ?? '');

Check failure on line 1920 in webdecoy.php

View workflow job for this annotation

GitHub Actions / PHP Coding Standards

$_POST['api_key'] not unslashed before sanitization. Use wp_unslash() or similar

// Decrypt if encrypted
if (!empty($api_key) && $this->is_encrypted($api_key)) {
Expand Down Expand Up @@ -1941,8 +1992,8 @@
return;
}

$ip = sanitize_text_field($_POST['ip'] ?? '');

Check failure on line 1995 in webdecoy.php

View workflow job for this annotation

GitHub Actions / PHP Coding Standards

$_POST['ip'] not unslashed before sanitization. Use wp_unslash() or similar
$reason = sanitize_text_field($_POST['reason'] ?? '');

Check failure on line 1996 in webdecoy.php

View workflow job for this annotation

GitHub Actions / PHP Coding Standards

$_POST['reason'] not unslashed before sanitization. Use wp_unslash() or similar
$duration = intval($_POST['duration'] ?? 24);

if (empty($ip) || !filter_var($ip, FILTER_VALIDATE_IP)) {
Expand All @@ -1968,7 +2019,7 @@
return;
}

$ip = sanitize_text_field($_POST['ip'] ?? '');

Check failure on line 2022 in webdecoy.php

View workflow job for this annotation

GitHub Actions / PHP Coding Standards

$_POST['ip'] not unslashed before sanitization. Use wp_unslash() or similar

if (empty($ip)) {
wp_send_json_error(['message' => __('Invalid IP address.', 'webdecoy')]);
Expand Down Expand Up @@ -2368,7 +2419,7 @@
return $transient;
}

$transient->response[WEBDECOY_PLUGIN_BASENAME] = (object) [

Check failure on line 2422 in webdecoy.php

View workflow job for this annotation

GitHub Actions / Static Analysis

Access to an undefined property object::$response.
'slug' => 'webdecoy',
'plugin' => WEBDECOY_PLUGIN_BASENAME,
'new_version' => $update_info['version'],
Expand Down
Loading