Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion admin/partials/settings-page.php
Original file line number Diff line number Diff line change
Expand Up @@ -367,16 +367,25 @@
<li><code>ip.country in ["CN","RU"] and req.path matches "^/wp-login"</code></li>
<li><code>ip.abuse_score &gt; 50</code></li>
<li><code>req.header("x-requested-with") == "XMLHttpRequest"</code></li>
<li><code>edge.script and req.path matches "^/wp-json"</code></li>
</ul>
<p class="description">
<?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'); ?>
<?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'); ?>
</p>
<?php if (!$has_api_key) : ?>
<p class="description webdecoy-error-text">
<?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'); ?>
</p>
<?php endif; ?>

<p class="description">
<strong><?php esc_html_e('edge.* fields', 'webdecoy'); ?></strong>
<?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'); ?>
</p>
<p class="description">
<?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'); ?>
</p>

<table class="widefat webdecoy-rules-table" style="margin-top:1em;max-width:60em;">
<thead>
<tr>
Expand Down
55 changes: 55 additions & 0 deletions sdk/src/Rules/Filter/Evaluator.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<int,array<string,mixed>> $args
Expand Down
35 changes: 35 additions & 0 deletions tests/FilterTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Loading