From e473ed32acf69c6aa0080aadd1a3ea4eab801e23 Mon Sep 17 00:00:00 2001 From: Chris Portscheller Date: Thu, 30 Jul 2026 08:59:32 -0500 Subject: [PATCH] feat(rules): edge.* filter fields for the validator's client class (#481) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- admin/partials/settings-page.php | 11 +++++- sdk/src/Rules/Filter/Evaluator.php | 55 ++++++++++++++++++++++++++++++ tests/FilterTest.php | 35 +++++++++++++++++++ 3 files changed, 100 insertions(+), 1 deletion(-) 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) {