Limen.Challenge (limen v0.2.1)

Copy Markdown View Source

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, which Limen.Plug verifies 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

binding(context)

@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.

pass_binding(ctx)

@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.

return_to(conn)

@spec return_to(Plug.Conn.t()) :: String.t()

The path and query of the current request, as a redirect target.

safe_return_to(path)

@spec safe_return_to(term()) :: String.t()

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/")
"/"