Skip to content

Latest commit

 

History

41 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 

Repository files navigation

Relintio

Relintio Agent for WordPress

distribution php license

The Relintio agent, packaged as a WordPress plugin.


A plugin that boots the Relintio agent on plugins_loaded at priority 0, before themes, before WooCommerce, before anything registers a route. It fetches an encrypted policy from the control plane, caches it in the system temp directory for eight to twelve seconds, and enforces it locally — allow, delay, challenge, decoy or block — without a policy round trip on the request path.

Plugins → Add New → Upload Plugin → relintio-agent-wordpress-plugin.zip → Activate

The ZIP comes from Dashboard → Deployment → WordPress with your licence key and API URL already substituted into it. There is nothing to configure to get a working install, and no wp-config.php edit.

Installation

Download the ZIP from the dashboard, upload it, activate it. Activation seeds the settings: protection on, your key in place, /wp-admin/*, /wp-login.php, /wp-json/*, /xmlrpc.php and /wp-cron.php excluded. Then open one public page so the plugin can report a first check-in, and verify that URL from the dashboard.

This plugin is not on WordPress.org, by design — it is generated per licence. Searching for "Relintio" under Plugins → Add New finds nothing, and that is expected rather than a mistake.

As a must-use plugin

To load the plugin's file before regular plugins — protection still boots on plugins_loaded at priority 0 either way:

<?php
// wp-content/mu-plugins/relintio-loader.php

require_once WP_PLUGIN_DIR.'/relintio-agent/relintio-agent.php';

WordPress never "activates" a must-use plugin, so the activation hook that seeds the options never runs. Without a licence key in up_license_key the boot function returns immediately and the site is unprotected, silently. Seed the options yourself:

wp option update up_license_key 'UP_LIVE_…'
wp option update up_enabled 1

Do not load the plugin from wp-config.php. That file runs before wp-includes/plugin.php, so add_action does not exist yet and the site white-screens on a fatal error.

Settings

Relintio in the admin menu, top level rather than buried under Settings.

Setting Option Default Meaning
Protection up_enabled on Master switch. Off means the agent never loads.
Licence key up_license_key from the ZIP Secret — see below.
Only paths up_only_paths empty One pattern per line. When set, only these are protected.
Except paths up_except_paths the five above One pattern per line. Falls back to the defaults if emptied.
Protect login up_protect_login off Removes /wp-login.php from the exception list.

Patterns are exact (/checkout), prefix (/account/*), or directory-style. Everything else — thresholds, geo rules, blocklists, sensitivity, SEO safety — is policy, managed in the dashboard and delivered in the synchronized ruleset.

The control plane is fixed at generation time as UP_PLATFORM_API_URL, which should read https://api.relintio.com/v1. A plugin built from the repository rather than the dashboard still carries the literal {{API_URL}} placeholder; every call then fails and the agent falls through to allowing traffic, so check that constant before wondering why the dashboard shows no check-in.

Protect login is off by default, and turning it on is a real decision. /wp-login.php is exactly where credential stuffing lands, so protecting it is the point; it is also where you go when something has gone wrong. Enable it once you have confirmed the agent scores your own traffic correctly, not on day one.

The licence key is a secret. It signs every outbound request, decrypts the ruleset and mints challenge passports, so anything holding it can forge all three. The overview masks it to its first eight characters, but the Protection tab renders the full key into a password field and the path-rules form carries it in a hidden input, so it sits in the admin HTML for anyone who can already reach that screen. Never put it in a theme, a shortcode, or anything else that renders into a page — that is what publishable keys are for, and those belong to the React and Shopify SDKs, not this one.

What is not protected

The plugin returns early, before any policy work, for WP-CLI, DOING_CRON, DOING_AJAX, REST_REQUEST and anything under is_admin(). Those are the paths where a false positive costs the most and a bot gains the least, and cron in particular has no browser on the other end to challenge.

That is broader than it sounds. admin-ajax.php carries a lot of front-end traffic on a typical site — form plugins, cart updates, search — and none of it is scored. The REST API is skipped both here and again by the default /wp-json/* exception. If your site's abuse arrives through either, this plugin is not where it gets stopped.

What happens on a request

Path filters first, then a static-asset bypass by extension, so a stylesheet never costs a policy lookup. Then the ruleset loads from cache — or over the network if stale — and if it is empty for any reason the request proceeds untouched.

With a policy in hand the order is fixed: honeypot trap, challenge passport, token exchange, IP whitelist, SEO safety, global blocklist, geo firewall, blocked CIDRs, honeypot headers, VPN reverse DNS, scanner signatures and bot regex, banned referrers, per-licence WAF rules, and finally the score.

Scoring

Signals are additive and independent; the total is clamped to 0–100.

Signal Weight Fires when
ua_empty +50 No User-Agent at all
rate_burst +35 Token bucket exhausted
ua_too_short +25 User-Agent under 10 characters
no_accept_language +20 No Accept-Language
generic_accept +15 Accept missing or exactly */*
post_no_referer +15 POST with no Referer
scanner_keyword +15 A policy scanner keyword appears in the UA — counted once
conn_close +10 Connection: close

The policy's sensitivity moves the thresholds rather than the weights, so raising it makes the same evidence count for more instead of inventing new evidence:

Tier medium high paranoid Response
SLOW 40 20 10 usleep for two seconds, then continue
CHALLENGE 60 40 25 Redirect to the hosted challenge
DECOY 75 60 50 200 with a maintenance page, or the policy's cloak_html
BLOCK 85 75 65 403, or 200 with cloak_html

A per-IP token bucket supplies rate_burst: 8 tokens per second, 24 in the burst, with route multipliers of 0.4 for /wp-login and 0.5 for /wp-admin, 2.0 for /assets/. State is one small file per IP under the system temp directory, shared across every PHP worker on the host.

Passport v2

A visitor who passes the challenge returns with ?up_token=<v2 token>. The plugin verifies it, mints its own passport and sets it as the relintio_passport cookie — HttpOnly, SameSite=Lax, Secure when the request arrived over HTTPS or on port 443.

A token is v2.<payload>.<signature>: base64url JSON carrying an absolute expiry and a binding hash, signed with HMAC-SHA256 under the licence key. Verification is offline by design — the licence key is the only secret involved, so the edge keeps working when the control plane does not. Both the signature and the binding are compared with hash_equals; a byte-by-byte comparison there is a timing oracle.

The binding is sha256(licenceKey|userAgent|acceptLanguage), truncated to 16 hex characters, over the raw header values. The agent caps the user agent at 1024 characters for logging and scoring; the binding deliberately uses the uncapped one, because the server hashes what it received and a truncated copy would disagree on every visitor with a long UA.

The predecessor was sha256('verified' + licenceKey) — one constant string, identical for every visitor of a site, valid for a week. One leaked cookie bypassed the agent entirely until the key was rotated. Tokens in that form are no longer accepted, so any still in the wild simply challenge again.

An invalid up_token is a block, not a pass-through. The only way to hold one is to have just passed the challenge, so a bad one is a forgery attempt rather than an accident.

Page caches and the passport. The cookie is set by PHP on a request the cache did not serve. A full-page cache in front of WordPress can answer before the plugin runs at all, which is fine for anonymous HTML and wrong for the challenge round trip — exclude the challenge return from the cache, or accept that a challenged visitor may see a cached page before their passport takes effect.

Request signing

Every outbound ingest call from the agent carries:

X-Relintio-Timestamp: 1785120000
X-Relintio-Nonce:     <16–128 chars of [A-Za-z0-9_-]>
X-Relintio-Signature: v1=<64 hex>

The signature is hash_hmac('sha256', "v1:{timestamp}:{nonce}:".hash('sha256', $body), $licenceKey). The server checks the timestamp within ±300 seconds, the nonce unused within 600 seconds per credential, and the signature in constant time — and burns the nonce last, so a forged request cannot consume one the real agent is about to use.

The server's agent_signature_mode has three settings. off checks nothing. optional accepts an absent signature but still rejects a bad one, so corrupting the header cannot be used as a downgrade. required rejects unsigned ingest with 401, and is both the default and the steady state.

Rule sync, telemetry, challenge init and geo lookup all funnel through one httpPost, which encodes the body once and hands the same string to whichever transport is available — here that is the WordPress HTTP API, so proxies, SSL quirks and host-level overrides are respected. Re-encoding the payload after signing produces a signature the server cannot reproduce, and the failure arrives as a 401 with nothing in any log to explain it. The agent's own heartbeat is the exception: it signs the same way but sends itself over cURL with a 50 ms cap, so it can be fire-and-forget.

The plugin wrapper signs its own calls too. It duplicates the signing rather than reaching into the agent class, because the wrapper runs on admin screens and in an AJAX handler where the agent has not booted. Those calls are the diagnostics connection test, a second non-blocking heartbeat sent with wp_remote_post behind a five-minute transient throttle on top of the one the agent already sends, and the Purge cache button, which deletes the local cache files and posts to /agent/purge-cache to bump rules_version for the whole fleet. The duplicate heartbeat costs nothing. The purge result line says which halves succeeded, so a remote call that did not land reads as Remote: signal not confirmed rather than as success.

Challenge disabled

challenge_enabled is a policy setting and it is also plan-gated: a licence without the bot challenge has it forced off server-side. Being over a monthly allowance used to do the same, and no longer does — overage warns and bills, and never takes a defence away.

Rather than have each agent read the flag, /agent/challenge/init refuses to issue a token when it is off and answers 200 with {"status": "challenge_disabled", "fallback": "allow"|"block"}. The 200 is deliberate — this is a policy answer, not an outage, and an agent that treated it as a failure would fail closed on a setting the customer turned off on purpose.

The plugin acts on it: allow returns and the request continues, block serves the block page. The settings screen also reads the flag out of the cached ruleset, so the overview reflects what the licence can actually do rather than what was asked for.

Honeypot

The invisible link injected into page HTML points at /.well-known/relintio-trap, and anything requesting it is blocked ahead of every other check. /.well-known/aura-trap — a leftover from the product's previous name — is still matched for one release but no longer planted, so crawlers already in flight against the old path are still caught while new ones only ever see the current one.

Injection happens twice on purpose: the agent's output buffer, and again on wp_footer at priority 9999. Caching plugins routinely gzip the response before an output-buffer callback ever fires, and a trap that is never planted catches nothing.

Hardening this build adds

The ruleset cache is written with an HMAC sidecar keyed on the licence key and chmodded to 0600. A cache whose MAC does not verify is deleted and refetched rather than trusted. On shared hosting — which is most WordPress — a writable rules file is a policy bypass, so it is checked on every read.

Any HTML the policy supplies is sanitized before it is stored: <script> blocks, on* handlers and javascript: or data: URIs are stripped out of cloak_html. A custom block page is content, not code, and it renders on the customer's own origin.

Updates

Updates come from the Relintio platform, not WordPress.org, and appear as a normal "Update available" row on the Plugins screen. Per-plugin auto-update is forced on for this slug so the promise on the settings screen is true; every other plugin's setting is left alone. The check result is cached for six hours in its own transient so an admin page load does not mean an API call.

Download links carry a fifteen-minute token, and WordPress caches its update transient far longer than that, so a fresh link is minted immediately before download rather than trusting the stale one. Settings and licence key survive an update; nothing needs re-entering.

Uninstall

Deactivate and delete from Plugins → Installed Plugins. No database tables are created and all telemetry lives server-side. The up_* options and the temp-directory cache remain — clear them with wp option delete and the Purge cache button if you are removing the plugin for good.

Edge cases

Blocks and challenges are reported in full; clean traffic is sampled at 1%. Events buffer in memory and flush from a shutdown handler, one POST per event, up to twenty, each capped at two seconds. ALLOW_SAMPLE_RATE is 0.01 and matches UsageMeterService::ALLOW_SAMPLE_RATE on the platform, which multiplies a reported allow back up by it — the two numbers have to agree or the same plan is worth a hundred times more on one runtime than another. It is a constant rather than a setting for that reason. On a busy site this is roughly one outbound request per hundred page views, plus every security event.

The first request from an unseen IP calls out synchronously. IP intelligence resolves country and ISP through /agent/geo-lookup, capped at three seconds and cached on disk for 24 hours — five minutes for a failure, so a control-plane blip does not pin a bad answer for a day.

Forwarding headers are trusted as sent. The client IP is taken from CF-Connecting-IP, then X-Forwarded-For, then X-Real-IP, then REMOTE_ADDR, with no check on who the peer is. Behind a proxy that is correct; exposed directly it means a client can choose the IP that gets rate-limited, geolocated and blocklisted.

An inactive licence stops protecting rather than blocking. expired and outdated write a marker into the cache and the plugin stands down. The site keeps serving, which is the right trade and also a silent one — alert on the dashboard's check-in status, not on the site.

Those two are the whole list. Any other answer leaves the last good policy in force. quota_exceeded used to be a third; going over an allowance now warns and bills, and never withdraws protection.

In production

Start in observe mode, watch a day of real traffic, and only then enforce. WooCommerce checkout, contact forms and any logged-out cart traffic are the places to look first — they are POSTs, and a POST with no Referer already carries points.

The plugin reports runtime kind wordpress on sync and heartbeat. Policy stays in the dashboard under Configuration; the plugin holds only the switches above.

Links

Security reports go to support@relintio.com, not to a public issue.

License

GPL-2.0-or-later, as declared in the plugin header.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages