Skip to content

Latest commit

 

History

2 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Relintio

@relintio/firebase

npm quickstart license

The Relintio agent for Firebase Cloud Functions.


withRelintio(handler) wraps the function you hand to onRequest, and the agent sees the request before your handler does. It scores it in the instance, from a ruleset already in memory, and either answers it outright — a block page, a decoy, a challenge redirect — or releases it and calls your handler with the same req and res it would have got anyway. That is the whole entry point; createAgent is beside it for when you want to build the agent once and share it across several exports. No proxy, no DNS change, no sidecar, and one extra await on the request path.

import { onRequest } from 'firebase-functions/v2/https';
import { withRelintio } from '@relintio/firebase';

export const api = onRequest(
  { secrets: ['RELINTIO_LICENSE_KEY'] },
  withRelintio(async (req, res) => {
    res.send('protected');
  }),
);

Installation

npm install @relintio/firebase

Install it in the functions/ directory — the package directory that is actually deployed, not the repository root. Node 18 or newer, per engines.

firebase-functions is an optional peer at >=4.0.0: this package never imports it, your function does, so nothing here pins your Firebase version.

Which engine this is

Not the edge one, despite this integration sitting beside Vercel and Supabase in the docs. The dependency in package.json is @relintio/agent at ^0.11.5 — the same server engine the Node and Express SDKs run — and not @relintio/edge-core, which is what @relintio/vercel and @relintio/supabase are built on.

That follows from what onRequest hands you. Cloud Functions run full Node.js and pass an Express request and response, not the Web-standard Request/Response an edge isolate deals in. The server engine is also the one that keeps state a stateless isolate cannot: the on-disk ruleset mirror, the reverse-DNS cache, the per-IP token buckets. A warm Cloud Function instance keeps all of it between invocations, which is most of the value of choosing this engine.

UltimateProtectorNodeAgent is re-exported here, so nothing needs a direct dependency on the engine to type or stub it.

Registration

There is no app-wide registration in Cloud Functions. Every export is its own entry point, so every export you want protected has to be wrapped:

export const api = onRequest(withRelintio(apiHandler, { agent }));
export const webhooks = onRequest(withRelintio(webhookHandler, { agent }));

Wrap the handler, then pass the result to onRequest. An unwrapped export is an unprotected one, and it is reachable at its own URL whether or not the rest of the deployment is wrapped.

Pass agent to share one instance, as above. Without it each wrapper builds its own on first use, and each one then fetches its own ruleset and hands every visitor a fresh, full rate-limit bucket — which is to say no rate limit at all across the deployment.

If you mount an Express app inside a Cloud Function, wrap at the onRequest boundary or use @relintio/express inside the app. Not both: the re-entry guard here only recognises this package's own wrapper (see edge cases).

The licence key

Server-side, and secret. UP_LIVE_… is the HMAC key that signs challenge passports and outbound request signatures, so anyone holding it can mint themselves a pass through the WAF it belongs to. It belongs in Secret Manager:

firebase functions:secrets:set RELINTIO_LICENSE_KEY

and then in the function's secrets array, as in the sample above, which is what makes it appear in process.env at runtime. Get the key from Dashboard → Deployment → Firebase.

It must never reach a browser. The public credential is a publishable key (pk_live_…), which can do exactly one thing — ask for a verdict — and belongs to the React, Vue and Shopify SDKs, not to this one. createAgent throws rather than start without a key at all; it cannot tell a browser from a server, so that boundary is yours to keep.

Configuration

withRelintio(handler, options) reads four options and passes the whole object through to the engine constructor.

Option Default Meaning
licenseKey process.env.RELINTIO_LICENSE_KEY UP_LIVE_…. Secret. Empty and absent both throw, naming the firebase functions:secrets:set command.
apiUrl process.env.RELINTIO_API_URL, else https://api.relintio.com/v1 Trailing slashes are trimmed by the engine.
agent built on the first invocation An agent to use instead of building one.
onError none Called as onError(error, req, res) with an unexpected fault. Observation only.

Everything else lands on UltimateProtectorNodeAgent:

Option Default Meaning
syncIntervalSeconds 10 Target sync cadence, floored at 10. Backoff and 80–120% jitter apply on top.
onlyPaths none If set, only these path prefixes are assessed.
exceptPaths none Path prefixes released without assessment.
onlyRegex none A regex the path must match to be assessed.
enforceTlsMinVersion true Block TLS below 1.2 — only when the request arrives on a TLS socket. See edge cases.
rateLimitPerMinute 120 Accepted, stored, and read by nothing. The limiter is a fixed token bucket.
agentKind firebase Not overridable: createAgent sets it after spreading your options, so the console can tell a Firebase install from a bare Node one.

A blocked request must answer

This is the failure this wrapper exists to prevent, and the one thing not to reintroduce while editing it.

handleExpress(req, res, next) has two kinds of exit. On the paths that release the request it calls next(). On the paths that answer it — the block page, the decoy, the 302 to the challenge, the 403 Invalid Token, the 302 that strips ?up_token after minting a passport, the 503 for an expired licence — it writes the response and returns without ever calling next. That is correct: the request is finished.

So a wrapper that awaits next waits for something that will never arrive. The invocation hangs until the platform's function timeout kills it, the customer is billed for the wall-clock, and what they see is Relintio hanging their API rather than protecting it — on precisely the requests the agent decided to stop.

The wrapper therefore awaits the promise, not the continuation:

const released = await new Promise((resolve) => {
  let proceed = false;
  ...
  agent.handleExpress(req, res, () => { proceed = true; }).then(done, ...);
});

next only sets a flag. The promise settling is what releases the await, and it settles on every path, answering or releasing. Then the other half of the same rule:

if (!released || res.headersSent || res.writableEnded) {
  return undefined;
}

Running the handler after the agent has answered writes over a finished response, which Node reports as ERR_HTTP_HEADERS_SENT — our name on a crash in the customer's log, and on a request we had already decided to block. released is false whenever next was not called; the two response checks catch the case where it was called and something wrote anyway.

The engine holds the same rule from the other side. #respondChallenge is called as return this.#respondChallenge(req, res, next), so it owns both exits: when the control plane answers challenge_disabled with fallback: "allow" it calls next() itself, and on every other path it sends a response. Anything added there that does neither hangs the request.

test/function.test.mjs pins both halves. does not hang when the agent answers without calling next races the wrapped call against a 300 ms timer and asserts it returned; does not run the handler once the agent has answered scripts an agent that sets headersSent and asserts the handler never ran.

What happens on a request

The agent is built lazily, on the first invocation rather than at module load, and kept on the wrapper as wrapped.agent. On a cold instance that first request awaits the ruleset fetch. After that getRules refreshes in the background once the interval has passed and returns the rules already in memory, so no later request pays for a sync — a difference worth knowing if you are comparing this with agents that fetch on the request path.

Assessment runs roughly in this order: path filters, the honeypot trap, ?up_token exchange and passport cookie, then the synced policy — SEO safety, global blocklist, TLS fingerprint, geo, CIDR, honeypot headers, scanner signatures, VPN shield, referrer rules, custom WAF rules — and finally additive scoring, which is clamped to 100 and read against fixed thresholds: 40 SLOW (a two-second delay, then release), 60 CHALLENGE, 75 DECOY, 85 BLOCK.

The ruleset is mirrored to os.tmpdir() as up_rules_<16 hex>.json, keyed by a SHA-256 of the licence key, with an .mac sidecar holding an HMAC of the file under the same key. A mismatch deletes the cache and refetches, so unlike a plain mirror this one is not a policy bypass for anything that can write the temp directory. On Cloud Functions that directory is memory-backed and counts against the instance's allocation.

Edge cases

This package is ESM. "type": "module" and a single exports entry pointing at src/index.js. require('@relintio/firebase') therefore works only on Node releases that allow requiring an ES module (20.19+, 22.12+) and throws ERR_REQUIRE_ESM on Node 18, which is still a selectable Cloud Functions runtime. Either give your functions/package.json "type": "module" and use import, as in the sample above, or run a Node version that permits the require.

An expired or revoked licence answers 503, not the handler. A sync that returns status: "expired" or "outdated" clears the rules, persists that state, and every subsequent request gets a 503 licence page until the state changes. This is the one place the agent does not release the request, and it is deliberate — but it means a lapsed licence takes the API down rather than leaving it unprotected. It is also the reason quota_exceeded was removed from that branch: overage is a billing event and must never stand a defence down.

An unreachable challenge service fails closed. #respondChallenge posts to /agent/challenge/init with a two-second timeout, and a timeout, a refused connection or a malformed answer ends in a 403. No challenge was issued, so nothing was passed, and the score that sent the visitor there still stands. Do not confuse this with challenge_disabled, which is a policy answer carrying its own allow or block fallback and is honoured.

Everything else fails open. A failed sync keeps the last policy it successfully decoded and backs off by powers of two to a five-minute ceiling; an unknown status, an undecryptable payload or an empty body counts as a failed sync rather than as a new policy. getRules throwing releases the request. No rules at all releases the request. A throw inside the agent, synchronous or asynchronous, is reported to onError and releases the request. A bug of ours should not be an outage of yours, and a security agent that blocks a page because it could not reach its control plane has turned our outage into the customer's.

onError is observation, not control. It cannot change the outcome, and it is wrapped in its own try/catch — a reporter that throws is swallowed rather than becoming the failure it was reporting. There is a test for that.

The re-entry guard only covers this package. It is Symbol.for('relintio.firebase.handled') on the request: the global symbol registry means two copies of @relintio/firebase in one deployment still agree, and a request that passes through two of these wrappers is assessed, logged and metered once. @relintio/express mounted inside the same function does not set or read it, so that combination assesses twice, and the customer sees one request billed as two.

TLS fingerprinting needs a TLS socket. extractTlsFingerprint returns null unless req.socket exposes getProtocol, which is not the case where TLS is terminated ahead of the instance — the usual arrangement on Cloud Functions. Both the fingerprint check and enforceTlsMinVersion are then skipped silently. The rest of the assessment is unaffected.

req.secure and req.ip decide more than they look like they do. req.secure selects https for the challenge return_url and adds Secure to the passport cookie; req.ip is the address that is scored, rate-limited and reported, with X-Forwarded-For honoured only when the peer address falls inside a synced Cloudflare range. Both depend on your framework's proxy trust settings behind Google's front end. Check them once, in a real deployment, before you trust the numbers on the dashboard.

Links

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

License

Proprietary. See LICENSE, which package.json declares as SEE LICENSE IN LICENSE.

The Relintio Proprietary License grants one permission: to use this software to integrate and operate the Relintio service under a valid, active licence obtained from Relintio. It reserves everything else — no copying, redistribution, modification, translation, reverse engineering or derivative works, and no removal of proprietary notices — and it disclaims all warranties. Reading the source to understand what runs in front of your traffic is the point of shipping it unminified; shipping any of it onward is not covered.

About

Relintio for Firebase Cloud Functions. Wraps an onRequest handler with the Node agent, reading the licence key from Secret Manager.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages