relintio-agent / agent
Relintio local WAF protection agent and telemetry middleware for PHP applications
Requires
- php: >=7.4
This package is auto-updated.
Last update: 2026-07-29 19:20:54 UTC
README
relintio-agent/agent
The Relintio agent for PHP.
One file, no dependencies, one static call. It fetches an encrypted policy from the control plane, caches it in the system temp directory for eight to twelve seconds, and enforces it before your framework boots — allow, delay, challenge, decoy or block, decided locally with no network round trip on the request path.
<?php require_once __DIR__.'/vendor/autoload.php'; RelintioAgent::protect(getenv('UP_LICENSE_KEY') ?: '', [ 'api_url' => 'https://api.relintio.com/v1', 'except_paths' => ['/health', '/webhooks/*'], ]);
Installation
composer require relintio-agent/agent
The package autoloads agent.php through Composer's files list, which defines the class and a fallback UP_PLATFORM_API_URL constant and nothing more. Protection starts when you call protect(), so an install that adds the dependency and stops there changes nothing about how the site behaves.
Put the call at the top of your front controller, above the framework bootstrap. Registering after the router means the route has already answered by the time the agent runs, and the diff looks identical either way.
For a site you would rather not edit at all, load it from the SAPI instead:
; php.ini, or .user.ini on shared hosting auto_prepend_file = /srv/app/vendor/relintio-agent/agent/agent.php
auto_prepend_file runs the file but does not call protect(); point it at a small bootstrap of your own that requires the agent and then calls it.
PHP 8.0 or newer. composer.json still declares >=7.4, which is stale — the code uses match, str_contains, str_ends_with and the mixed type, all of which are 8.0. On 7.4 it is a parse error, not a graceful degradation.
Configuration
protect() takes the licence key and an options array. That is the whole surface; the agent reads no environment variables of its own.
| Option | Type | Default | Meaning |
|---|---|---|---|
api_url |
string |
UP_PLATFORM_API_URL |
Control plane. https://api.relintio.com/v1. |
only_paths |
string[] |
— | Protect only these. Exact, prefix (/product/*), or directory-style. |
except_paths |
string[] |
— | Skip these, checked after only_paths. |
Everything else — thresholds, geo rules, blocklists, sensitivity, SEO safety — is policy, managed in the dashboard and delivered in the synchronized ruleset. There is nothing to configure twice.
The shipped file has a placeholder for the API URL. agent.php defines UP_PLATFORM_API_URL as the literal string {{API_URL}}, which the dashboard substitutes when it generates your package. A copy taken straight from the repository resolves that placeholder as a hostname, every call fails, and the agent falls through to allowing traffic. Pass api_url explicitly, or define UP_PLATFORM_API_URL before the file loads.
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. Keep it in the environment or in a file outside the web root, never in a repository, and never in code that reaches a browser — that is what publishable keys are for, and those belong to the React and Shopify SDKs, not this one.
What happens on a request
Path filters run 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 the cache is stale — and if it is empty for any reason the agent returns and 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. Cheap checks first, and everything that can allow outright before anything that blocks.
curl_safety_paths from the policy marks a path as machine-facing: scanner signatures, bot regex and scoring are skipped there, while blocklists, geo and CIDR rules still apply. It is the narrow version of a bypass, and usually what an API route actually wants — but it does not currently take effect: the flag is computed in protect() and read in enforce(), where that variable is not in scope, so the checks it should skip run anyway. Until that is fixed, use except_paths or a dashboard bypass rule.
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 |
No single signal reaches BLOCK on medium. An empty user agent, a missing Accept-Language and a generic Accept together come to exactly 85, which is why a naive script is stopped and a merely unusual browser is not. On paranoid, one absent header is enough to earn a delay — check what that does to your own monitoring before turning it on.
Rate limiting
A per-IP token bucket: 8 tokens per second, 24 in the burst, refilled continuously rather than reset on a boundary. Exhausting it contributes +35 to the score; it does not block on its own. Route multipliers scale both the refill rate and the ceiling — 2.0 for /assets/, 0.7 for /api/, 0.5 for /wp-admin, 0.4 for /login, /auth and /wp-login.
State is one small JSON file per IP under the system temp directory, so the limit is shared across every PHP worker on the host and survives a restart. It is not shared between hosts.
Passport v2
A visitor who passes the challenge returns with ?up_token=<v2 token>. The agent 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 $_SERVER 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.
Request signing
Every outbound ingest call 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.
/agent/verify, /agent/log, /agent/challenge/init and /agent/geo-lookup all go through one private httpPost, which encodes the body once and hands the same string to whichever transport is available — the WordPress HTTP API, cURL, or a stream context. The heartbeat has its own non-blocking cURL call and signs the same way. This is the part that is easy to get wrong: re-encoding the payload after signing produces a signature the server cannot reproduce, because key order and escaping both change the bytes, and the failure arrives as a 401 with nothing in any log to explain it.
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.
This agent acts on it: allow returns and the request continues, block serves the block page. Without that branch it would fall through to the block below, which is right for block and exactly wrong for a customer who switched the challenge off precisely to stop bouncing real visitors.
Honeypot
The invisible link injected into 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.
Failure behaviour
The agent fails open, in every direction. An unreachable control plane returns the stale cache, or nothing; an undecryptable payload returns the stale cache; an empty ruleset returns immediately. A licence that comes back expired or outdated writes a marker into the cache and stops protecting rather than blocking anyone — the site keeps serving without Relintio in front of it.
Those two are the whole list, and everything else the control plane can say leaves the last good policy in force. An unknown status, an empty body, an unreadable one: the agent keeps enforcing what it already had and retries. quota_exceeded used to be a third state that stood the agent down; it is gone from the platform, and going over an allowance is now a billing event that warns and bills. Protection is never withdrawn over a bill.
That is a deliberate trade and it has a cost worth naming: a lapsed subscription is silent. Nothing in the site's own behaviour will tell you protection stopped, so the dashboard's check-in status is the thing to alert on, not the site itself.
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, on the worker, after the body has been sent.
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 to the internet it means a client can choose the IP that gets rate-limited, geolocated and blocklisted. Terminate at a proxy that strips inbound forwarding headers, or accept that IP-based rules are advisory.
The rules cache is not integrity-checked here. It is a plain JSON file under the system temp directory, readable and writable by anything running as the same user. The WordPress build of this agent verifies an HMAC sidecar and chmods the file to 0600; this one does not. On shared hosting, that difference is a policy bypass.
Machine callers score as bots. curl, python-requests and Go's default client send no Accept-Language and a generic Accept, which is 35 points before anything else. Exclude health checks and webhooks explicitly with except_paths or dashboard bypass rules — never by lowering sensitivity globally, and never by carving out login, registration, checkout or password reset.
In production
Start in observe mode, watch a day of real traffic, and only then enforce. The dashboard shows what the agent scored and why, so the question to settle before enforcement is whether the traffic you expect is scored the way you expect.
Restart long-running workers after deploying — FPM and any queue workers — since a preloaded agent.php stays resident.
Run at least one deploy with agent_signature_mode at optional and the adoption page open. It records which credentials are signing and which are not, and that is the only way to know whether flipping to required will take part of the fleet dark.
Links
Security reports go to support@relintio.com, not to a public issue.
License
Proprietary. The package ships no LICENSE file; the terms are at relintio.com/legal/terms.