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) {