Skip to content

Latest commit

 

History

2 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Relintio

@relintio/express

npm quickstart license

The Relintio agent for Express.


One app.use() in front of your stack. relintio(options) builds an UltimateProtectorNodeAgent from @relintio/agent and returns an Express request handler that scores every request before your routes run — allow, slow, challenge, decoy or block — from a ruleset synced from the control plane and mirrored to the OS temp directory. What this package adds over the engine's own middleware is the boundary: an unexpected fault releases the request and is reported to onError, never handed to next(err). The entry point is the named export relintio, which is also the default export.

import express from 'express';
import { relintio } from '@relintio/express';

const app = express();

app.use(relintio({
  licenseKey: process.env.RELINTIO_LICENSE_KEY,
  apiUrl: 'https://api.relintio.com/v1',
  exceptPaths: ['/healthz'],
  onError: (error, req) => reportToSentry(error, { url: req.originalUrl }),
}));

app.use(express.json());
app.get('/', (req, res) => res.send('protected'));

app.listen(3000);

Installation

npm install @relintio/express

Node 18 or newer. express is a peer dependency pinned at >=4.21.2; the engine is a direct dependency at @relintio/agent@^0.11.4, so there is one copy of it whether or not you also install it yourself. The package is ESM only — "type": "module" with a single . export and no CommonJS build — so require('@relintio/express') does not resolve.

The licence key is a secret. It is the HMAC key that signs challenge passports and every outbound call to the control plane, so anything holding it can mint a passport and walk through the WAF. Keep it in the environment and never in code that reaches a browser; that is what publishable keys are for, and they belong to the browser SDKs, not to this one.

Registration

Mount it once, at the root, before your body parser and before any routing:

app.use(relintio({ licenseKey: process.env.RELINTIO_LICENSE_KEY, apiUrl: '...' }));
app.use(express.json());

A body parser that runs first has already read a 40 MB upload from a client the agent was about to block, so the request has cost you what it was going to cost you. A router that runs first serves the routes registered above the middleware without ever being assessed.

To protect one subtree, mount it there. The agent reads req.originalUrl, so onlyPaths and exceptPaths are matched against the path the visitor sent, not the path Express leaves after stripping a mount point — app.use('/api', relintio({ exceptPaths: ['/api/health'] })), not ['/health'].

Mounting it twice is safe. The handler marks the request with Symbol.for('relintio.express.handled') and the second mount calls next() immediately, so one page view is assessed, logged and metered once rather than billed as two.

relintio(...) returns the handler with the agent attached as .agent, for the rare case you need to ask it something directly:

const guard = relintio({ /* ... */ });
app.use(guard);

const rules = await guard.agent.getRules('shop.example.com');

Configuration

relintio(options) throws if options is not an object; the engine constructor then throws licenseKey required and apiUrl required. Everything is passed through to the engine except onError and agentKind, which this package reads and sets.

Option Default Meaning
licenseKey Required. UP_LIVE_…. Signs passports and outbound requests. Secret.
apiUrl Required. https://api.relintio.com/v1. Trailing slashes are trimmed.
syncIntervalSeconds 10 Target ruleset cadence. Floored at 10; backoff and jitter apply on top.
rateLimitPerMinute 120 Accepted and stored. Nothing reads it — see edge cases.
onlyPaths all paths Protect only these. An entry ending * matches by prefix; anything else must match the path exactly.
exceptPaths none Skip these, checked before onlyPaths. Same matching rule.
onlyRegex none A PHP-style /pattern/flags string, for parity with the other agents. A pattern that does not compile protects everything rather than nothing.
enforceTlsMinVersion true Block a TLS handshake below 1.2 when the socket exposes one. Only a literal false turns it off.
onError none Called as (error, req, res) with an unexpected fault. Cannot change the outcome.
agentKind express The label the dashboard filters on. Truncated to 32 characters.

allowSampleRate is deliberately not an option. Allowed requests are reported at a fixed 1% and the platform multiplies that back up; an install sampling at a different rate would report a number the platform then corrected with the wrong constant. Blocks, challenges, decoys and slows are never sampled.

What happens on a request

A throttled heartbeat is fired without being awaited, then path matching runs. Requests to /.well-known/relintio-trap and /.well-known/aura-trap are blocked before anything else — those paths are only reachable by following an invisible link the agent injects. ?up_token is exchanged for a relintio_passport cookie (Path=/, Max-Age, HttpOnly, SameSite=Lax, plus Secure when req.secure) and redirected to the clean path; an invalid one is 403 Invalid Token, because the only way to hold a token is to have just passed the challenge. A valid passport cookie short-circuits to your handler without re-scoring.

Then the ruleset decides, in order: whitelisted IPs, SEO safety, the global blocklist, TLS version and fingerprint, geo, blocked CIDRs, honeypot headers, scanner signatures and bot regex, the VPN shield, referrer checks, custom WAF rules, and only then the additive score. Signals are summed and clamped to 100.

Signal Weight Fires when
Empty user agent +50 No User-Agent at all
Rate burst +35 The per-IP token bucket is exhausted
Short user agent +25 Present but under 10 characters
No Accept-Language +20 Header absent
Generic Accept +15 Absent or exactly */*
POST without referer +15 POST and no Referer
Scanner keyword +15 UA matches a keyword from the synced ruleset
Connection: close +10 Header is close
Tier Score Response
ALLOW 0–39 next()
SLOW 40–59 Two-second delay, then next()
CHALLENGE 60–74 302 to the hosted challenge
DECOY 75–84 200 with a maintenance page, or cloak_html if set
BLOCK 85–100 403 block page, or 200 with cloak_html if set

The rate limiter is a per-IP token bucket of 8 tokens per second with a burst of 24, refilled continuously, scaled by route: 0.4 on /login, /auth and /wp-login, 0.5 on /wp-admin, 0.7 on /api/, 2.0 on /assets/. Exhausting it contributes +35 and never blocks on its own.

The failure boundary

This is the reason the package exists, and both halves of it are covered in test/express.test.mjs.

A fault never reaches next(err). The engine's own Express middleware once ended in .catch(next), and Express's default error handler answers 500 — so a bug in a security agent became an outage on the customer's endpoint, which is precisely the failure the rest of the design refuses to accept. Here an asynchronous rejection and a synchronous throw both call the same release(), which calls next() with no argument. Every deliberate failure path in the engine already behaves this way: an unreachable control plane, a getRules that rejects, a geo lookup that does not resolve and a 200 carrying no readable ruleset all release the request rather than holding it.

A blocked request answers; it does not continue. #respondBlock, #respondSoftBlock, #respondDecoy and the geo block all write a status and end the response, and none of them calls next. release() checks res.headersSent and res.writableEnded before continuing, so a fault thrown after the agent has written its block page leaves that response alone instead of letting your route write over the top of it — which Express would report as ERR_HTTP_HEADERS_SENT, our bug appearing as your corrupted response.

The one place the continuation is called from inside a terminating branch is challenge_disabled with fallback: "allow": the control plane has answered that the challenge is switched off for this licence and said what to do instead. Anything else — a fallback of block, a missing fallback, a timeout, an unusable body — writes 403.

Edge cases

A fault here is silent unless you ask for it. Without onError nothing is logged; the request is released and no trace of the fault remains. That is the right trade for an agent that must not be why a page fails to load, but it means the only place a fault surfaces is your reporter. A reporter that throws is swallowed on purpose — it is the last thing standing between a fault of ours and a 500 of yours.

An expired licence fails closed, and stays that way until you restart. When /agent/verify answers expired or outdated the agent serves 503 with a "Subscription Expired" page that reloads itself every six seconds, and that state is written to disk, so it survives a restart until a sync clears it. getRules skips refreshing while the status is expired, so renewing the licence does not lift the 503 in a long-running process — the process has to come back up. This is the one path in the agent that does not fail open.

rateLimitPerMinute does nothing. The constructor validates and stores it, and no other line in the engine reads it. The bucket is the fixed 8/second, burst 24, route-scaled one described above. Setting it is not an error and not a control.

The first request after boot pays for the sync. There is no background task. With no ruleset in memory or on disk, getRules awaits /agent/verify inside the request; after that a stale ruleset refreshes in the background while requests score against the copy already in memory. Failures back off by powers of two to a five-minute ceiling with 80–120% jitter.

The disk cache is integrity-checked, not encrypted. Rules are mirrored to <temp>/up_rules_<sha256(licenceKey)[0..16]>.json with an HMAC sidecar keyed by the licence key; a mismatched or missing MAC deletes the file and forces a re-fetch. The contents are still readable by anything that can read your temp directory.

domain comes from the Host header, which the client controls. It is the name reported on every sync and log, and the host the challenge return_url is built from. Normalise or reject it at your ingress if you cannot trust it.

Content injection patches the response. When the ruleset has Obsidian active, the agent wraps res.send and res.end to splice a honeypot link and a client-side script before </body> in text/html responses. Responses with a Content-Encoding other than identity are left alone.

Links

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

License

Proprietary. See LICENSE — the Relintio Proprietary License, which grants use of this package solely to integrate and operate the Relintio service under a valid, active licence, and reserves all other rights: no redistribution, no modification, no reverse engineering, and no removal of proprietary notices. The software is provided as is, without warranty.

About

Express adapter for the Relintio agent. One app.use() for bot mitigation, threat detection and adaptive content protection, mounted before the body parser.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages