Skip to content

Latest commit

 

History

History
244 lines (166 loc) · 17.5 KB

File metadata and controls

244 lines (166 loc) · 17.5 KB
Relintio

com.relintio:relintio-agent

maven central java license

The Relintio agent for Java.


A Jakarta Servlet filter that scores every request inside your own process. It keeps a rule set synchronized from the control plane on a daemon thread — every ten seconds or so, with jitter and exponential backoff — and decides allow, challenge or block against the cached copy, with no network round trip on the request path. Telemetry leaves on a second daemon thread, so a slow control plane cannot slow a response. The only things on the classpath are the JDK and the servlet API you already have.

import com.relintio.agent.Agent;
import com.relintio.agent.AgentConfig;
import com.relintio.agent.RelintioFilter;

import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.Ordered;

@Configuration
public class RelintioConfig {

    @Bean
    public FilterRegistrationBean<RelintioFilter> relintioFilter() {
        Agent agent = new Agent(new AgentConfig(
                System.getenv("UP_LICENSE_KEY"),
                "example.com",
                "https://api.relintio.com/v1",
                10));

        agent.startSync();

        FilterRegistrationBean<RelintioFilter> registration =
                new FilterRegistrationBean<>(new RelintioFilter(agent));

        registration.addUrlPatterns("/*");
        registration.setOrder(Ordered.HIGHEST_PRECEDENCE);

        return registration;
    }
}

Installation

<dependency>
    <groupId>com.relintio</groupId>
    <artifactId>relintio-agent</artifactId>
    <version>0.1.6</version>
</dependency>
implementation 'com.relintio:relintio-agent:0.1.6'

Compiled for Java 11, so it runs on 11 and everything after. jakarta.servlet-api 6 is a provided dependency — Spring Boot 3, Tomcat 10, Jetty 11 and anything else on Jakarta EE 9+ already supply it. There are no other dependencies, deliberately: an agent that dragged in a JSON library or an HTTP client would be a classpath conflict waiting for the one application that pins a different version.

Registration

Register before your own filters and controllers. Ordered.HIGHEST_PRECEDENCE in the snippet above is the whole point of the bean — a filter registered after your router still runs, but only once the request has been served, and the diff looks identical either way. This is the most common way an install ends up looking finished while protecting nothing.

Without Spring, web.xml works and the filter builds its own agent from init-params:

<filter>
    <filter-name>relintio</filter-name>
    <filter-class>com.relintio.agent.RelintioFilter</filter-class>
    <init-param><param-name>licenseKey</param-name><param-value>${UP_LICENSE_KEY}</param-value></init-param>
    <init-param><param-name>apiUrl</param-name><param-value>https://api.relintio.com/v1</param-value></init-param>
    <init-param><param-name>domain</param-name><param-value>example.com</param-value></init-param>
</filter>

A missing licenseKey throws ServletException from init, so a misconfigured deployment fails at startup rather than running unprotected. That check exists only on this path: new AgentConfig(null) is accepted in silence, which is why the Spring bean reads the key from the environment where its absence is visible.

Put <filter-mapping> for relintio first. Servlet containers apply mappings in declaration order.

Configuration

AgentConfig has three constructors; the four-argument one is the only one that takes a domain.

Parameter Type Default Meaning
licenseKey String Required in practice. Secret — see below.
domain String "" The licensed domain, sent on every sync.
apiUrl String https://api.relintio.com/v1 Trailing slashes are stripped.
syncIntervalSeconds int 10 Target cadence. Floored at 10; jitter and backoff apply on top.

The licence key is a secret. It signs every outbound call and it is the HMAC key a passport is minted under, so anything holding it can forge both. Keep it in the environment, never in a repository, and never anywhere it can reach a browser — that is what publishable keys are for, and they belong to the React and Shopify SDKs, not this one.

What happens on a request

The filter reads the first entry of X-Forwarded-For, falling back to getRemoteAddr(); the User-Agent header, or ""; getRequestURI(); and every request header, so a header rule has something to match against. It scores them against the cached rules, hands the result to the telemetry thread, and then either answers or calls chain.doFilter.

Because X-Forwarded-For is trusted unconditionally, an application reachable without going through your proxy will accept whatever client IP a caller claims. Terminate that at the proxy, or make sure the container is not routable from outside it.

Rules are additive. Each one that matches contributes its score and may escalate the action; the totals then decide on their own:

Total score Action
0–49 allow
50–99 challenge
100 or more block

A rule whose own action is block blocks whatever the total says. A rule marked challenge escalates unless something already said block.

The dashboard assigns 100 to a block rule and 60 to a challenge rule, which has a consequence worth knowing before you write the third rule: two challenge rules matching the same request add to 120 and block it. Nothing warns you, and the dashboard still calls both rules challenges.

type Compared against Condition
ip client IP equals
path request URI contains
user_agent User-Agent header contains
header the request headers contains

contains is case-insensitive, and so is equals — invisible for a path_block, and real for an IPv6 address written 2001:DB8::1 in the dashboard and sent lowercase on the wire. That is why contracts/rule-conditions-v1.json settles it rather than leaving each runtime to choose.

A header rule carries its own small grammar in the pattern. Without a colon it names a header and tests that it is present with a non-empty value; an empty header is not a signal. With one — X-Forwarded-Host: evil.example — the part before names the header and the part after is matched against its value, case-insensitively on both sides, with the whitespace around the colon trimmed. A repeated header is joined the way HTTP defines it, so a rule sees every value under a name and not just the first.

regex is a fourth condition the dashboard does not emit today. It is evaluated with java.util.regex — as a regular expression, not as a substring — and a pattern that will not compile is skipped rather than thrown out of the decision path, where the only thing to catch it would be the visitor. An unrecognised type or condition contributes nothing, deliberately: a rule that silently means something else is worse than one that does nothing.

RuleConditionsConformanceTest loads that contract file and asserts every vector in it, so this agent cannot drift from the other eleven without a red build.

Block is 403 with {"error":"Request blocked by security policy."}.

A challenge is also 403, carrying X-Relintio-Action: challenge and X-Relintio-Challenge-URL — the same pair the .NET and Go middlewares send, and exactly what the React SDK's interceptor watches for: it opens the challenge overlay and replays the request that was refused. Before, this was a bare 401 with neither header, so a React front end talking to a Java back end saw an ordinary auth failure and never offered the visitor a way through.

The URL comes from POST /agent/challenge/init, which answers with an opaque token, and points at the hosted /security-check page. It is never a local path: /_relintio/challenge is a route this SDK does not serve, so redirecting there would be a challenge tier that dead-ends in a 404. The licence key never appears in it.

Agent.challenge returns a ChallengeOutcome with three cases rather than a nullable URL, because the answer has three shapes:

Outcome What the filter does
REDIRECT 403 plus the two headers
DISABLED with shouldBlock() blocks — the customer turned the challenge off and asked for these to be blocked
DISABLED without it lets the request through — the customer turned the challenge off and asked for these to be let through
UNAVAILABLE blocks — no challenge was issued, so nothing was passed

challenge_disabled arrives as a 200 because it is a policy answer and not a failure. Collapsing it into the same absent-URL as "the call failed" is what made an entire risk band silently allow in another SDK.

The last row used to let the request through too, and that was wrong in a way the middle row is not. A request only reaches this branch because the engine already judged it suspicious enough to challenge; when the token service times out or answers with something unusable, no challenge was issued and nothing was passed, so that verdict still stands. Failing open there makes an unreachable control plane the way in for exactly the traffic the challenge tier exists to stop, and it put this SDK at odds with the reference PHP agent, Node, Go and Ruby, all of which block.

Passport v2

Passport is a static, dependency-free implementation of the v2 token: v2.<base64url payload>.<base64url signature>, HMAC-SHA256 under the licence key, carrying an absolute expiry and a binding hash. Verification is entirely local, because the edge has to keep deciding when the control plane is unreachable. Both the signature and the binding are compared with MessageDigest.isEqual, in constant time.

The binding is sha256(licenceKey|userAgent|acceptLanguage) truncated to 16 hex characters, over the raw header values. Trim them, lowercase them or cap them for logging and this agent computes a different hash from the challenge server, which locks out every visitor who just passed.

It replaced sha256("verified" + licenceKey) — one constant string, the same for every visitor of a site, valid for a week. One leaked cookie bypassed the agent until the key was rotated. Tokens in that shape are no longer parsed at all.

RelintioFilter does not use any of this. The filter never reads the cookie and never redeems an up_token; the only part of Passport the shipped agent calls is signingHeaders. The verification and minting side is a primitive for you to wire into whatever handles your challenge exchange:

public static boolean holdsPassport(HttpServletRequest request, String licenseKey) {
    Cookie[] cookies = request.getCookies();
    if (cookies == null) {
        return false;
    }

    for (Cookie cookie : cookies) {
        if (!Passport.COOKIE.equals(cookie.getName())) {
            continue;
        }

        // Raw header values, uncapped: the challenge server hashed what it
        // received, and a trimmed copy disagrees on every visitor.
        return Passport.verify(
                cookie.getValue(),
                licenseKey,
                request.getHeader("User-Agent"),
                request.getHeader("Accept-Language"),
                0) != null;
    }

    return false;
}

Passport.mint(ttl, …) produces the cookie value; set it Path=/, HttpOnly, Secure over HTTPS, with Max-Age equal to the ttl. Pass the token's ttl through Passport.clampTtl first — it arrives signed, but a bug upstream should not be able to mint a ten-year cookie. Passport.CLOCK_SKEW_SECONDS is the 60 seconds of drift allowed between the challenge server and this process.

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 HMAC-SHA256("v1:" + timestamp + ":" + nonce + ":" + sha256(body), licenceKey). The server checks the timestamp within ±300 seconds, the nonce unused within 600 seconds for that credential, and the signature in constant time — burning the nonce last, so a forged request cannot consume one the real agent is about to use.

agent_signature_mode on the server has three settings. off checks nothing. optional allows 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.

Every call goes through one private signedRequest, which encodes the payload to bytes once and hands that same array to both Passport.signingHeaders and BodyPublishers.ofByteArray. This is the part that is easy to get wrong: passing the string to BodyPublishers.ofString instead leaves two independent encodings of the same text, and a signature over bytes that never went on the wire is one the server cannot reproduce — every call fails, with nothing in any log to say why. An endpoint added around the chokepoint rather than through it does not degrade gracefully; it 401s, and the edge goes blind. SignedRequestTest catches exactly that: it stands a JDK HttpServer on loopback and recomputes the signature from the bytes that arrived.

Edge cases

Nothing is protected until the first sync lands. The rule list starts empty and an empty list scores zero, so every request is allowed. startSync() runs the first sync on the scheduler thread, not inline — call syncRules() once before you start serving if you need rules in place from the first request.

A dead control plane is a silent allow, not an outage. syncRulesInternal swallows every exception and a failed sync leaves the previous rules in place. A 200 carrying no rules array counts as a failure for the same reason: an unknown status, an error envelope or an empty object is not a policy, and applying it as one would empty the cache and leave the site unprotected. That is what quota_exceeded used to do deliberately — a billing state switching off a defence — and what any unrecognised answer did by accident. Sync failures back off to a five-minute ceiling with jitter on every interval, so a fleet restarting together does not synchronize into a thundering herd. There is no inactive-licence handling in this agent: an expired subscription stops new rules arriving, it does not start refusing traffic.

Telemetry is queued without a bound. The executor takes an unbounded queue, so a control plane that is reachable but slow leaves work accumulating in heap rather than dropping it. deinit() waits up to five seconds per executor before forcing them down.

Clean traffic is sampled at 1%. Agent.ALLOW_SAMPLE_RATE is fixed at 0.01 and must match UsageMeterService::ALLOW_SAMPLE_RATE on the platform, which multiplies a reported allow back up by it to estimate real traffic. Reporting every allowed request — which this SDK used to do — inflates the customer's meter a hundredfold against an install of the compiled engine on the same plan. It is a constant rather than a setting for that reason. Blocks, challenges, decoys and slows are never sampled: they are the security record, and the platform counts them at face value.

The rule parser is positional. Rules are matched out of the response with one regular expression that expects type, pattern, condition, score, action in that order and nothing between them. That is exactly what the control plane emits today. A reordered or extended object would not fail loudly — those rules would simply not exist.

Machine callers are only as risky as your rules make them. This agent scores nothing on its own: with no rules synchronized, curl and a browser are indistinguishable to it. Exclude health checks and webhooks with dashboard bypass rules — never by lowering protection 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.

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.

deinit() on shutdown. The two threads are daemons, so they will not hold the JVM open, but a container that reuses the classloader will otherwise leave them running against a filter nobody is calling.

Links

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

License

MIT. See LICENSE.