diff --git a/admin/partials/settings-page.php b/admin/partials/settings-page.php
index 7ffecf1..9575beb 100644
--- a/admin/partials/settings-page.php
+++ b/admin/partials/settings-page.php
@@ -367,9 +367,10 @@
ip.country in ["CN","RU"] and req.path matches "^/wp-login"
ip.abuse_score > 50
req.header("x-requested-with") == "XMLHttpRequest"
+ edge.script and req.path matches "^/wp-json"
- , >=, <, <=, in, not in, matches (regex).', 'webdecoy'); ?>
+ , >=, <, <=, in, not in, matches (regex).', 'webdecoy'); ?>
@@ -377,6 +378,14 @@
+
+
+
+
+
+
+
+
diff --git a/sdk/src/Rules/Filter/Evaluator.php b/sdk/src/Rules/Filter/Evaluator.php
index 8727e2a..f396bf6 100644
--- a/sdk/src/Rules/Filter/Evaluator.php
+++ b/sdk/src/Rules/Filter/Evaluator.php
@@ -160,9 +160,64 @@ private static function resolveProperty(array $path, RuleContext $context)
}
}
+ // The edge validator's verdict (#481). Matching this always worked via
+ // req.header("x-wd-class") — the plugin forwards the whole HTTP_* set into
+ // the rule context — so a named field is not new capability. It is
+ // discoverable, spelled once instead of in every site owner's expression,
+ // and survives us renaming a header.
+ //
+ // Derived from the headers already on the context rather than added as a
+ // constructor argument, so a released plugin's RuleContext signature does
+ // not change under anyone.
+ if ($namespace === 'edge') {
+ $class = self::edgeClass($context);
+ $clearance = $context->header('x-wd-clearance');
+ $clearance = is_string($clearance) && trim($clearance) !== '' ? trim($clearance) : null;
+
+ switch ($prop) {
+ case 'present':
+ return $class !== null || $clearance !== null;
+ case 'clearance':
+ return $clearance;
+ case 'class':
+ // null when the edge did not classify, so a comparison against
+ // it is false rather than accidentally true — the property that
+ // matters when a rule decides whether to serve someone less.
+ return $class;
+ case 'verified':
+ return $class === 'verified';
+ case 'crawler':
+ return $class === 'crawler';
+ case 'script':
+ return $class === 'script';
+ case 'browser':
+ return $class === 'browser';
+ default:
+ return null;
+ }
+ }
+
return null;
}
+ /**
+ * The sensor's client classification, or null when the edge did not classify.
+ *
+ * A value outside the closed set is DROPPED rather than passed through: it
+ * means version skew or something that is not our worker, and either way a
+ * rule must not act on it. Absence must never read as 'browser'.
+ */
+ private static function edgeClass(RuleContext $context): ?string
+ {
+ $raw = $context->header('x-wd-class');
+ if (!is_string($raw)) {
+ return null;
+ }
+ $v = strtolower(trim($raw));
+
+ return in_array($v, ['verified', 'crawler', 'script', 'browser'], true) ? $v : null;
+ }
+
/**
* @param string[] $object
* @param array> $args
diff --git a/tests/FilterTest.php b/tests/FilterTest.php
index 0e083e2..467dc9f 100644
--- a/tests/FilterTest.php
+++ b/tests/FilterTest.php
@@ -118,6 +118,41 @@ function evalExpr(string $expr, RuleContext $ctx): bool
$true(!evalExpr('req.header("x-missing") == "y"', $bare), 'missing header = undefined = false');
});
+// edge.* fields (#481) — the edge validator's verdict, forwarded to the origin.
+$t('edge.* fields read the validator tag', function () use ($true) {
+ $c = ctx_enriched('/', 'GET', ['x-wd-class' => 'script', 'x-wd-clearance' => 'valid']);
+ $true(evalExpr('edge.class == "script"', $c), 'edge.class');
+ $true(evalExpr('edge.script', $c), 'edge.script shorthand');
+ $true(evalExpr('edge.present', $c), 'edge.present');
+ $true(evalExpr('edge.clearance == "valid"', $c), 'edge.clearance');
+ $true(!evalExpr('edge.browser', $c), 'script is not browser');
+ // The raw-header form still works, so no site owner has to migrate.
+ $true(evalExpr('req.header("x-wd-class") == "script"', $c), 'raw header form still matches');
+});
+
+$t('edge.* is false when the edge did not front the request', function () use ($true) {
+ // The important case. A rule using this decides whether to serve someone
+ // less, so "no edge here" must never read as a class — least of all browser.
+ $c = ctx_enriched('/', 'GET', []);
+ $true(!evalExpr('edge.present', $c), 'no tag = not present');
+ $true(!evalExpr('edge.class == "script"', $c), 'undefined class = comparison false');
+ $true(!evalExpr('edge.browser', $c), 'absence is not browser');
+ $true(!evalExpr('edge.verified', $c), 'absence is not verified');
+});
+
+$t('an unrecognised class value is dropped, not passed through', function () use ($true) {
+ // Outside the closed set means version skew or something that is not our
+ // worker. Either way a rule must not act on it.
+ $c = ctx_enriched('/', 'GET', ['x-wd-class' => 'definitely-a-human']);
+ $true(!evalExpr('edge.present', $c), 'unknown class is not presence');
+ $true(!evalExpr('edge.class == "definitely-a-human"', $c), 'unknown value not readable');
+});
+
+$t('edge.class is case- and whitespace-tolerant', function () use ($true) {
+ $c = ctx_enriched('/', 'GET', ['x-wd-class' => ' Crawler ']);
+ $true(evalExpr('edge.crawler', $c), 'trimmed and lowercased');
+});
+
echo "\nFilterRule\n";
$t('parses at construction and fires with configured action', function () use ($eq) {