Skip to content

Commit 7d2fa7c

Browse files
authored
Merge pull request #23 from WebDecoy/feat/honeytoken
feat: honeytoken hidden-link injection (#4)
2 parents 87fb796 + 7b6d57b commit 7d2fa7c

5 files changed

Lines changed: 318 additions & 4 deletions

File tree

admin/partials/settings-page.php

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,34 @@
209209
</td>
210210
</tr>
211211
</table>
212+
213+
<h3><?php esc_html_e('Honeytoken', 'webdecoy'); ?></h3>
214+
<p class="description">
215+
<?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'); ?>
216+
</p>
217+
218+
<table class="form-table">
219+
<tr>
220+
<th scope="row"><?php esc_html_e('Enable Honeytoken', 'webdecoy'); ?></th>
221+
<td>
222+
<label>
223+
<input type="checkbox" name="webdecoy_options[honeytoken_enabled]" value="1"
224+
<?php checked($options['honeytoken_enabled'] ?? true); ?> />
225+
<?php esc_html_e('Inject the hidden decoy link and enforce its path', 'webdecoy'); ?>
226+
</label>
227+
</td>
228+
</tr>
229+
<tr>
230+
<th scope="row"><?php esc_html_e('Daily Rotation', 'webdecoy'); ?></th>
231+
<td>
232+
<label>
233+
<input type="checkbox" name="webdecoy_options[honeytoken_rotate]" value="1"
234+
<?php checked($options['honeytoken_rotate'] ?? false); ?> />
235+
<?php esc_html_e('Rotate the decoy path daily (yesterday\'s stays armed briefly so an in-progress crawl still trips)', 'webdecoy'); ?>
236+
</label>
237+
</td>
238+
</tr>
239+
</table>
212240
</div>
213241

214242
<!-- Good Bots Tab -->

changelog.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
* 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.
55
* Added: Rule engine — deterministic rules evaluated before heuristic scoring; first DENY/THROTTLE wins, with dry-run (log without blocking). Parity with @webdecoy/node.
66
* 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.
7+
* 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.
78
* 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.
89
* 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.
910
* Added: JS execution verification — detects non-JS HTTP scrapers (e.g., Scrapling Fetcher, curl_cffi)
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
if (!defined('ABSPATH')) {
6+
exit;
7+
}
8+
9+
/**
10+
* Honeytoken (F4 deception layer) — automatic sitewide hidden-link injection.
11+
*
12+
* Plants a visually-hidden, non-followable link on front-end pages pointing at a
13+
* secret per-site path (`/__wd/{token}`). A real visitor never sees or clicks it
14+
* (offscreen, aria-hidden, tabindex -1, nofollow/noindex); only a client that
15+
* parses the HTML and follows links — a scraper — requests the path. The path is
16+
* armed as a tripwire, so a hit is a deterministic, zero-false-positive
17+
* automated-intent signal.
18+
*
19+
* WordPress owns page rendering, so injection is automatic — unlike @webdecoy/node
20+
* where the developer must embed the link by hand.
21+
*
22+
* The token is derived by HMAC from a stored per-site secret, so it is
23+
* unguessable and needs no extra storage. With rotation enabled it changes daily
24+
* (yesterday's token stays armed as a grace window so a crawler mid-crawl still
25+
* trips).
26+
*
27+
* Deliberately no robots.txt Disallow entry: a `Disallow: /__wd/` line would
28+
* advertise the trap, and robots-honoring good bots never follow a nofollow
29+
* hidden link anyway.
30+
*/
31+
class WebDecoy_Honeytoken
32+
{
33+
/** Base path for honeytoken tripwires (mirrors @webdecoy/node's default). */
34+
private const BASE_PATH = '/__wd';
35+
36+
/** Token length (hex chars), matching node's randomBytes(6).toString('hex'). */
37+
private const TOKEN_LEN = 12;
38+
39+
/** @var bool */
40+
private $rotate;
41+
42+
public function __construct(bool $rotate = false)
43+
{
44+
$this->rotate = $rotate;
45+
}
46+
47+
/**
48+
* Get (or lazily create) the per-site secret the tokens are derived from.
49+
*/
50+
private function secret(): string
51+
{
52+
$secret = get_option('webdecoy_honeytoken_secret', '');
53+
if (!is_string($secret) || $secret === '') {
54+
$secret = bin2hex(random_bytes(16));
55+
// Autoload so it's cheap to read on every request.
56+
add_option('webdecoy_honeytoken_secret', $secret, '', 'yes');
57+
}
58+
return $secret;
59+
}
60+
61+
/**
62+
* Derive a token from the secret for a given label.
63+
*/
64+
private function token(string $label): string
65+
{
66+
return substr(hash_hmac('sha256', $label, $this->secret()), 0, self::TOKEN_LEN);
67+
}
68+
69+
/**
70+
* The path advertised in the injected link (today's, or the stable one).
71+
*/
72+
public function primary_path(): string
73+
{
74+
if ($this->rotate) {
75+
return self::BASE_PATH . '/' . $this->token('day:' . gmdate('Y-m-d'));
76+
}
77+
return self::BASE_PATH . '/' . $this->token('stable');
78+
}
79+
80+
/**
81+
* All paths that should be armed as tripwires right now. With rotation this
82+
* is today + yesterday (grace window); otherwise just the stable path.
83+
*
84+
* @return string[]
85+
*/
86+
public function active_paths(): array
87+
{
88+
if (!$this->rotate) {
89+
return [self::BASE_PATH . '/' . $this->token('stable')];
90+
}
91+
92+
$today = self::BASE_PATH . '/' . $this->token('day:' . gmdate('Y-m-d'));
93+
$yesterday = self::BASE_PATH . '/' . $this->token('day:' . gmdate('Y-m-d', time() - DAY_IN_SECONDS));
94+
95+
return array_values(array_unique([$today, $yesterday]));
96+
}
97+
98+
/**
99+
* The hidden decoy link HTML. Byte-for-byte the same hiding technique as
100+
* @webdecoy/node's honeytoken() so behavior matches across SDKs.
101+
*/
102+
public function render_link(): string
103+
{
104+
$path = esc_attr($this->primary_path());
105+
return '<a href="' . $path . '" aria-hidden="true" tabindex="-1" rel="nofollow noindex" '
106+
. 'style="position:absolute;left:-9999px;top:auto;width:1px;height:1px;overflow:hidden">.</a>';
107+
}
108+
109+
/**
110+
* Whether the honeytoken link should be injected on the current request.
111+
* Skips logged-in users, feeds, and non-HTML contexts so a genuine visitor
112+
* or authenticated session can never trip it.
113+
*/
114+
public function should_inject(): bool
115+
{
116+
if (is_admin() || wp_doing_ajax() || is_feed()) {
117+
return false;
118+
}
119+
if (defined('REST_REQUEST') && REST_REQUEST) {
120+
return false;
121+
}
122+
if (defined('DOING_CRON') && DOING_CRON) {
123+
return false;
124+
}
125+
if (is_user_logged_in()) {
126+
return false;
127+
}
128+
return true;
129+
}
130+
}

tests/HoneytokenTest.php

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* Tests for WebDecoy_Honeytoken (F4 hidden-link injection).
7+
*
8+
* Self-contained: defines the handful of WordPress functions the class touches
9+
* (guarded so they don't collide with other tests or a real WP runtime), then
10+
* exercises token derivation, the hidden-link markup, rotation, and that a
11+
* honeytoken path actually trips a tripwire. Run: php tests/run.php
12+
*/
13+
14+
use WebDecoy\Rules\RuleContext;
15+
use WebDecoy\Rules\RuleEngine;
16+
use WebDecoy\Rules\RuleResult;
17+
use WebDecoy\Rules\TripwireRule;
18+
19+
if (!defined('ABSPATH')) {
20+
define('ABSPATH', '/tmp/');
21+
}
22+
if (!defined('DAY_IN_SECONDS')) {
23+
define('DAY_IN_SECONDS', 86400);
24+
}
25+
if (!isset($GLOBALS['__wd_opts'])) {
26+
$GLOBALS['__wd_opts'] = [];
27+
}
28+
if (!function_exists('get_option')) {
29+
function get_option($k, $d = false)
30+
{
31+
return $GLOBALS['__wd_opts'][$k] ?? $d;
32+
}
33+
}
34+
if (!function_exists('add_option')) {
35+
function add_option($k, $v, $a = '', $b = 'yes')
36+
{
37+
$GLOBALS['__wd_opts'][$k] = $v;
38+
return true;
39+
}
40+
}
41+
if (!function_exists('esc_attr')) {
42+
function esc_attr($s)
43+
{
44+
return htmlspecialchars((string) $s, ENT_QUOTES);
45+
}
46+
}
47+
48+
require_once dirname(__DIR__) . '/includes/class-webdecoy-honeytoken.php';
49+
50+
$t = ['TestRunner', 'test'];
51+
$eq = ['TestRunner', 'assertSame'];
52+
$true = ['TestRunner', 'assertTrue'];
53+
54+
echo "\nWebDecoy_Honeytoken\n";
55+
56+
$t('derives a stable /__wd/{12-hex} path from the per-site secret', function () use ($eq, $true) {
57+
$h = new WebDecoy_Honeytoken(false);
58+
$p = $h->primary_path();
59+
$true(strpos($p, '/__wd/') === 0, 'under /__wd/');
60+
$eq(strlen('/__wd/') + 12, strlen($p), '12-hex token');
61+
$eq($p, $h->primary_path(), 'deterministic across calls');
62+
$eq($p, (new WebDecoy_Honeytoken(false))->primary_path(), 'stable across instances (same secret)');
63+
});
64+
65+
$t('stable mode arms exactly the advertised path', function () use ($eq) {
66+
$h = new WebDecoy_Honeytoken(false);
67+
$paths = $h->active_paths();
68+
$eq(1, count($paths));
69+
$eq($h->primary_path(), $paths[0]);
70+
});
71+
72+
$t('persists an unguessable secret to options', function () use ($true) {
73+
(new WebDecoy_Honeytoken(false))->primary_path();
74+
$secret = $GLOBALS['__wd_opts']['webdecoy_honeytoken_secret'] ?? '';
75+
$true(is_string($secret) && strlen($secret) >= 16, 'secret stored');
76+
});
77+
78+
$t('hidden link matches the node hiding technique', function () use ($true) {
79+
$h = new WebDecoy_Honeytoken(false);
80+
$link = $h->render_link();
81+
$true(strpos($link, 'href="' . $h->primary_path() . '"') !== false, 'href = primary path');
82+
$true(strpos($link, 'aria-hidden="true"') !== false, 'aria-hidden');
83+
$true(strpos($link, 'tabindex="-1"') !== false, 'tabindex -1');
84+
$true(strpos($link, 'rel="nofollow noindex"') !== false, 'nofollow noindex');
85+
$true(strpos($link, 'position:absolute;left:-9999px') !== false, 'offscreen');
86+
});
87+
88+
$t('rotation arms today + yesterday and differs from stable', function () use ($eq, $true) {
89+
$r = new WebDecoy_Honeytoken(true);
90+
$paths = $r->active_paths();
91+
$eq(2, count($paths), 'today + yesterday grace window');
92+
$true(in_array($r->primary_path(), $paths, true), 'today is armed');
93+
$true($r->primary_path() !== (new WebDecoy_Honeytoken(false))->primary_path(), 'rotating != stable');
94+
});
95+
96+
$t('a honeytoken path trips a tripwire; normal pages pass', function () use ($eq) {
97+
$h = new WebDecoy_Honeytoken(false);
98+
$engine = new RuleEngine([new TripwireRule(['paths' => $h->active_paths(), 'includeDefaults' => false])]);
99+
$hit = $engine->evaluate(new RuleContext('9.9.9.9', $h->primary_path(), 'GET', 'scrapy', [], 1700000000000));
100+
$eq(RuleResult::DENY, $hit->action);
101+
$eq('tripwire', $hit->rule);
102+
$miss = $engine->evaluate(new RuleContext('9.9.9.9', '/', 'GET', 'human', [], 1700000000000));
103+
$eq(RuleResult::ALLOW, $miss->action);
104+
});

webdecoy.php

Lines changed: 55 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,12 @@ private function load_options(): void
206206
'tripwire_action' => 'block', // block | throttle
207207
'tripwire_dry_run' => false, // record violations without blocking
208208

209+
// Honeytoken: auto-inject a hidden decoy link on front-end pages and
210+
// arm its secret path as a tripwire. Only link-following scrapers
211+
// ever hit it — deterministic, zero false positives. On by default.
212+
'honeytoken_enabled' => true,
213+
'honeytoken_rotate' => false, // rotate the token daily (with grace)
214+
209215
// Form Protection
210216
'protect_comments' => true,
211217
'protect_login' => true,
@@ -575,6 +581,12 @@ private function init_hooks(): void
575581
add_action('wp_enqueue_scripts', [$this, 'enqueue_clearance_client']);
576582
}
577583

584+
// Honeytoken: inject the hidden decoy link on front-end pages. The path
585+
// itself is armed as a tripwire in build_rule_engine().
586+
if (!empty($this->options['honeytoken_enabled']) && !is_admin()) {
587+
add_action('wp_footer', [$this, 'inject_honeytoken_link'], 99);
588+
}
589+
578590
// JS execution verification: inject challenge token meta tag and report page serve
579591
// Only active when scanner is enabled and API key is configured (premium)
580592
if ($this->options['scanner_enabled'] && !is_admin() && $this->is_premium()) {
@@ -647,6 +659,7 @@ public function load_includes(): void
647659
require_once WEBDECOY_PLUGIN_DIR . 'includes/class-webdecoy-pow.php';
648660
require_once WEBDECOY_PLUGIN_DIR . 'includes/class-webdecoy-behavioral-scorer.php';
649661
require_once WEBDECOY_PLUGIN_DIR . 'includes/class-webdecoy-violation-reporter.php';
662+
require_once WEBDECOY_PLUGIN_DIR . 'includes/class-webdecoy-honeytoken.php';
650663

651664
if (class_exists('WooCommerce')) {
652665
require_once WEBDECOY_PLUGIN_DIR . 'includes/class-webdecoy-woocommerce.php';
@@ -788,16 +801,32 @@ private function build_rule_engine(): ?\WebDecoy\Rules\RuleEngine
788801
{
789802
$rules = [];
790803

804+
$action = ($this->options['tripwire_action'] ?? 'block') === 'throttle'
805+
? \WebDecoy\Rules\RuleResult::THROTTLE
806+
: \WebDecoy\Rules\RuleResult::DENY;
807+
$dryRun = !empty($this->options['tripwire_dry_run']);
808+
791809
if (!empty($this->options['tripwire_enabled'])) {
792810
$rules[] = new \WebDecoy\Rules\TripwireRule([
793811
'paths' => is_array($this->options['tripwire_paths'] ?? null) ? $this->options['tripwire_paths'] : [],
794812
'prefixes' => is_array($this->options['tripwire_prefixes'] ?? null) ? $this->options['tripwire_prefixes'] : [],
795813
'patterns' => is_array($this->options['tripwire_patterns'] ?? null) ? $this->options['tripwire_patterns'] : [],
796814
'includeDefaults' => !empty($this->options['tripwire_include_defaults']),
797-
'action' => ($this->options['tripwire_action'] ?? 'block') === 'throttle'
798-
? \WebDecoy\Rules\RuleResult::THROTTLE
799-
: \WebDecoy\Rules\RuleResult::DENY,
800-
'dryRun' => !empty($this->options['tripwire_dry_run']),
815+
'action' => $action,
816+
'dryRun' => $dryRun,
817+
]);
818+
}
819+
820+
// Arm the honeytoken path(s) as a tripwire. Independent of the general
821+
// tripwire toggle: if honeytokens are on, their secret path is always
822+
// enforced (the hidden link is only useful if a hit actually trips).
823+
if (!empty($this->options['honeytoken_enabled'])) {
824+
$honeytoken = new WebDecoy_Honeytoken(!empty($this->options['honeytoken_rotate']));
825+
$rules[] = new \WebDecoy\Rules\TripwireRule([
826+
'paths' => $honeytoken->active_paths(),
827+
'includeDefaults' => false,
828+
'action' => $action,
829+
'dryRun' => $dryRun,
801830
]);
802831
}
803832

@@ -808,6 +837,26 @@ private function build_rule_engine(): ?\WebDecoy\Rules\RuleEngine
808837
return new \WebDecoy\Rules\RuleEngine($rules);
809838
}
810839

840+
/**
841+
* Inject the hidden honeytoken decoy link into the page footer. Only
842+
* link-following scrapers ever request the path it points at.
843+
*/
844+
public function inject_honeytoken_link(): void
845+
{
846+
if (empty($this->options['honeytoken_enabled'])) {
847+
return;
848+
}
849+
850+
$honeytoken = new WebDecoy_Honeytoken(!empty($this->options['honeytoken_rotate']));
851+
if (!$honeytoken->should_inject()) {
852+
return;
853+
}
854+
855+
// The link markup is a fixed, safe template (the path is esc_attr'd
856+
// inside render_link()); emit it verbatim.
857+
echo $honeytoken->render_link(); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
858+
}
859+
811860
/**
812861
* Build the rule context for the current request (trusted-proxy-resolved IP,
813862
* path, method, UA, and headers — the Cookie header carries wd_clearance).
@@ -1411,6 +1460,8 @@ public function sanitize_options(array $input): array
14111460
$sanitized['tripwire_patterns'] = $this->sanitize_pattern_list($input['tripwire_patterns'] ?? '');
14121461
$sanitized['tripwire_action'] = in_array($input['tripwire_action'] ?? 'block', ['block', 'throttle'], true) ? $input['tripwire_action'] : 'block';
14131462
$sanitized['tripwire_dry_run'] = !empty($input['tripwire_dry_run']);
1463+
$sanitized['honeytoken_enabled'] = !empty($input['honeytoken_enabled']);
1464+
$sanitized['honeytoken_rotate'] = !empty($input['honeytoken_rotate']);
14141465

14151466
// Form Protection
14161467
$sanitized['protect_comments'] = !empty($input['protect_comments']);

0 commit comments

Comments
 (0)