The proof-of-work challenge, modelled on the Anubis proxy.
When a policy decides :challenge, a browser navigating to the page gets a
small interstitial instead. A script on it (served by Limen, no third-party
assets, compatible with a strict Content-Security-Policy) searches, in Web
Workers, for a nonce such that SHA-256(token <> nonce) starts with the
requested number of zero bits, and posts it back. Each extra bit doubles
the expected work; the default 16 bits take a fraction of a second on a
phone, and cost a scraper the same for every client identity it uses.
Everything is stateless on the server:
- the challenge token (
Limen.Challenge.Token) is HMAC-signed and bound to the client's prefix, JA4 fingerprint and user agent, with an expiry, the difficulty and a random salt; - verification checks the signature, the expiry, the binding and the leading zero bits: one HMAC and one SHA-256;
- solved tokens go into a rotating Bloom filter (
Limen.Challenge.Replay) so each can be used once; - success sets a pass cookie (
Limen.Challenge.Pass), again HMAC-signed and bound to the same identity, whichLimen.Plugverifies on the fast path without collecting any other signal.
A pass stops working when the client's prefix, JA4 or user agent change,
for example when a phone moves between networks; the client then solves a
new challenge. The :bind option of the :challenge configuration picks
what passes and socket tokens are bound to (see pass_binding/1), for
example bind: [:ja4, :user_agent] to keep passes across networks.
Challenge tokens, which live for minutes, are always bound to all three.
Clients without JavaScript
With no_js: {:meta_refresh, seconds} (the default), the page includes a
<noscript> meta refresh to a wait endpoint that accepts the token once the
given number of seconds have passed since it was issued. That costs
automated clients time instead of CPU, and keeps the site usable with
JavaScript disabled, including for some assistive technologies. With
no_js: :deny, such clients are asked to enable JavaScript. To let
specific clients through without any challenge, allow-list them in the
policy (Limen.Policy.Default allows list(:allow)).
Endpoints
Limen serves, under the :path of the :challenge configuration
(/__limen by default): solver.js, worker.js and challenge.css,
POST verify for solutions and GET wait for the no-JavaScript path.
Summary
Functions
The identity a challenge token is bound to, as the iodata its MAC covers: the client's prefix, JA4 and user agent.
Whether a request is a navigation that can render the challenge page.
The identity a pass cookie or socket token is bound to, as the iodata its
MAC covers: the parts of the identity the instance's :bind option names,
by default all three, as for challenge tokens.
The path and query of the current request, as a redirect target.
Validates a redirect target: a local path, never another origin.
Functions
@spec binding(Limen.Context.t()) :: iolist()
The identity a challenge token is bound to, as the iodata its MAC covers: the client's prefix, JA4 and user agent.
@spec pass_binding(Limen.Context.t()) :: iolist()
The identity a pass cookie or socket token is bound to, as the iodata its
MAC covers: the parts of the identity the instance's :bind option names,
by default all three, as for challenge tokens.
A part left out is left empty, in its place, so passes issued under one
:bind setting are not accepted under another.
@spec return_to(Plug.Conn.t()) :: String.t()
The path and query of the current request, as a redirect target.
Validates a redirect target: a local path, never another origin.
iex> Limen.Challenge.safe_return_to("/articles?page=2")
"/articles?page=2"
iex> Limen.Challenge.safe_return_to("//evil.example/")
"/"
iex> Limen.Challenge.safe_return_to("https://evil.example/")
"/"