Skip to content

Commit da563ca

Browse files
authored
feat(rules): edge.* filter fields for the validator's client class (#481) (#69)
Matching the edge tag already worked on a stock install: collect_request_headers() forwards the whole $_SERVER['HTTP_*'] set into the rule context, so req.header("x-wd-class") == "script" has always been a valid expression. Nobody knew, because nothing said so — the header was documented as "watch it in your logs" and the Fields line in the settings screen never mentioned it. So this is discoverability, not capability: edge.class, edge.clearance, edge.present, plus edge.verified / edge.crawler / edge.script / edge.browser. One spelling instead of one per site owner, and it survives us renaming a header. The raw-header form still works and has a test pinning that. ABSENCE IS NOT A CLASS A rule using this decides whether to serve someone less, so: - edge.class is null when the validator did not classify, making a comparison against it false rather than accidentally true - a value outside the closed set is DROPPED — outside it means version skew or something that is not our worker - edge.present false means "no information", not "human", and the settings screen says so in those words Derived from the headers already on RuleContext rather than added as a constructor argument, so a released plugin's RuleContext signature does not change under anyone. THE CACHE CAVEAT IS IN THE UI, NOT ONLY THE DOCS The settings screen now states that these fields are safe for blocking, throttling, logging and metering, and that serving different page CONTENT on a cacheable URL is not: Cloudflare's cache key ignores this header below Enterprise, so the first cached variant is served to everyone including Googlebot, and on a cache hit the site never runs at all. A site owner reading the field list is exactly the person about to make that mistake. No version bump — releasing is a separate decision, and goes through bin/release-all.sh. Refs WebDecoy/app#481, WebDecoy/app#477
1 parent 6056e3c commit da563ca

3 files changed

Lines changed: 100 additions & 1 deletion

File tree

admin/partials/settings-page.php

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -367,16 +367,25 @@
367367
<li><code>ip.country in ["CN","RU"] and req.path matches "^/wp-login"</code></li>
368368
<li><code>ip.abuse_score &gt; 50</code></li>
369369
<li><code>req.header("x-requested-with") == "XMLHttpRequest"</code></li>
370+
<li><code>edge.script and req.path matches "^/wp-json"</code></li>
370371
</ul>
371372
<p class="description">
372-
<?php esc_html_e('Fields: ip.vpn / ip.proxy / ip.tor / ip.relay / ip.hosting, ip.country / ip.country_name / ip.city / ip.timezone, ip.asn / ip.asn_org, ip.abuse_score / ip.total_reports / ip.is_high_risk, req.path / req.method / req.ip / req.user_agent, req.header("name"). Operators: and, or, not, ==, !=, >, >=, <, <=, in, not in, matches (regex).', 'webdecoy'); ?>
373+
<?php esc_html_e('Fields: ip.vpn / ip.proxy / ip.tor / ip.relay / ip.hosting, ip.country / ip.country_name / ip.city / ip.timezone, ip.asn / ip.asn_org, ip.abuse_score / ip.total_reports / ip.is_high_risk, req.path / req.method / req.ip / req.user_agent, req.header("name"), edge.class / edge.clearance / edge.present and the shorthands edge.verified / edge.crawler / edge.script / edge.browser. Operators: and, or, not, ==, !=, >, >=, <, <=, in, not in, matches (regex).', 'webdecoy'); ?>
373374
</p>
374375
<?php if (!$has_api_key) : ?>
375376
<p class="description webdecoy-error-text">
376377
<?php esc_html_e('Note: ip.* fields require a WebDecoy Cloud API key (for IP enrichment). Without one, ip.* conditions are always false; req.* rules still work.', 'webdecoy'); ?>
377378
</p>
378379
<?php endif; ?>
379380

381+
<p class="description">
382+
<strong><?php esc_html_e('edge.* fields', 'webdecoy'); ?></strong>
383+
<?php esc_html_e('are set by the WebDecoy edge validator running on Cloudflare in front of this site. edge.class is one of: verified (an identity Cloudflare attested — Googlebot and friends; never degrade these), crawler (says it is a crawler, unproven), script (an HTTP client library, not a browser), browser (nothing non-human fired). If the validator is not in front of a request, edge.present is false and every edge.* condition is false — that means "no information", not "human".', 'webdecoy'); ?>
384+
</p>
385+
<p class="description">
386+
<?php esc_html_e('Safe to use for blocking, throttling, logging and metering. Do NOT use an edge.* rule to serve different page CONTENT on a cacheable URL: Cloudflare\'s cache key ignores this header outside Enterprise plans, so the first version cached is served to everyone including Googlebot, and on a cache hit your site never runs at all.', 'webdecoy'); ?>
387+
</p>
388+
380389
<table class="widefat webdecoy-rules-table" style="margin-top:1em;max-width:60em;">
381390
<thead>
382391
<tr>

sdk/src/Rules/Filter/Evaluator.php

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,9 +160,64 @@ private static function resolveProperty(array $path, RuleContext $context)
160160
}
161161
}
162162

163+
// The edge validator's verdict (#481). Matching this always worked via
164+
// req.header("x-wd-class") — the plugin forwards the whole HTTP_* set into
165+
// the rule context — so a named field is not new capability. It is
166+
// discoverable, spelled once instead of in every site owner's expression,
167+
// and survives us renaming a header.
168+
//
169+
// Derived from the headers already on the context rather than added as a
170+
// constructor argument, so a released plugin's RuleContext signature does
171+
// not change under anyone.
172+
if ($namespace === 'edge') {
173+
$class = self::edgeClass($context);
174+
$clearance = $context->header('x-wd-clearance');
175+
$clearance = is_string($clearance) && trim($clearance) !== '' ? trim($clearance) : null;
176+
177+
switch ($prop) {
178+
case 'present':
179+
return $class !== null || $clearance !== null;
180+
case 'clearance':
181+
return $clearance;
182+
case 'class':
183+
// null when the edge did not classify, so a comparison against
184+
// it is false rather than accidentally true — the property that
185+
// matters when a rule decides whether to serve someone less.
186+
return $class;
187+
case 'verified':
188+
return $class === 'verified';
189+
case 'crawler':
190+
return $class === 'crawler';
191+
case 'script':
192+
return $class === 'script';
193+
case 'browser':
194+
return $class === 'browser';
195+
default:
196+
return null;
197+
}
198+
}
199+
163200
return null;
164201
}
165202

203+
/**
204+
* The sensor's client classification, or null when the edge did not classify.
205+
*
206+
* A value outside the closed set is DROPPED rather than passed through: it
207+
* means version skew or something that is not our worker, and either way a
208+
* rule must not act on it. Absence must never read as 'browser'.
209+
*/
210+
private static function edgeClass(RuleContext $context): ?string
211+
{
212+
$raw = $context->header('x-wd-class');
213+
if (!is_string($raw)) {
214+
return null;
215+
}
216+
$v = strtolower(trim($raw));
217+
218+
return in_array($v, ['verified', 'crawler', 'script', 'browser'], true) ? $v : null;
219+
}
220+
166221
/**
167222
* @param string[] $object
168223
* @param array<int,array<string,mixed>> $args

tests/FilterTest.php

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,41 @@ function evalExpr(string $expr, RuleContext $ctx): bool
118118
$true(!evalExpr('req.header("x-missing") == "y"', $bare), 'missing header = undefined = false');
119119
});
120120

121+
// edge.* fields (#481) — the edge validator's verdict, forwarded to the origin.
122+
$t('edge.* fields read the validator tag', function () use ($true) {
123+
$c = ctx_enriched('/', 'GET', ['x-wd-class' => 'script', 'x-wd-clearance' => 'valid']);
124+
$true(evalExpr('edge.class == "script"', $c), 'edge.class');
125+
$true(evalExpr('edge.script', $c), 'edge.script shorthand');
126+
$true(evalExpr('edge.present', $c), 'edge.present');
127+
$true(evalExpr('edge.clearance == "valid"', $c), 'edge.clearance');
128+
$true(!evalExpr('edge.browser', $c), 'script is not browser');
129+
// The raw-header form still works, so no site owner has to migrate.
130+
$true(evalExpr('req.header("x-wd-class") == "script"', $c), 'raw header form still matches');
131+
});
132+
133+
$t('edge.* is false when the edge did not front the request', function () use ($true) {
134+
// The important case. A rule using this decides whether to serve someone
135+
// less, so "no edge here" must never read as a class — least of all browser.
136+
$c = ctx_enriched('/', 'GET', []);
137+
$true(!evalExpr('edge.present', $c), 'no tag = not present');
138+
$true(!evalExpr('edge.class == "script"', $c), 'undefined class = comparison false');
139+
$true(!evalExpr('edge.browser', $c), 'absence is not browser');
140+
$true(!evalExpr('edge.verified', $c), 'absence is not verified');
141+
});
142+
143+
$t('an unrecognised class value is dropped, not passed through', function () use ($true) {
144+
// Outside the closed set means version skew or something that is not our
145+
// worker. Either way a rule must not act on it.
146+
$c = ctx_enriched('/', 'GET', ['x-wd-class' => 'definitely-a-human']);
147+
$true(!evalExpr('edge.present', $c), 'unknown class is not presence');
148+
$true(!evalExpr('edge.class == "definitely-a-human"', $c), 'unknown value not readable');
149+
});
150+
151+
$t('edge.class is case- and whitespace-tolerant', function () use ($true) {
152+
$c = ctx_enriched('/', 'GET', ['x-wd-class' => ' Crawler ']);
153+
$true(evalExpr('edge.crawler', $c), 'trimmed and lowercased');
154+
});
155+
121156
echo "\nFilterRule\n";
122157

123158
$t('parses at construction and fires with configured action', function () use ($eq) {

0 commit comments

Comments
 (0)