Lua module to allow ip (or not) from CrowdSec API.
This library is used by different bouncers :
ALTCHA is a proof-of-work captcha that this bouncer issues and
verifies itself. There is no third-party service to call and no account to hold, so
SECRET_KEY and SITE_KEY are ignored. By default one third-party dependency
remains — the widget bundle is fetched from cdn.jsdelivr.net, version-pinned with
subresource integrity — and ALTCHA_WIDGET_FILE/ALTCHA_WIDGET_PATH remove even
that by serving the bundle yourself (see below). Either way, a visitor whose browser
never receives the script is shown an explanatory message by the stock template
rather than a blank form.
Important
Visitors must reach the site over HTTPS to solve the captcha. The widget
derives keys with crypto.subtle, which browsers expose only in a secure context,
and it throws Secure context (HTTPS) required. before doing any work otherwise.
The bouncer therefore never serves a captcha to a plain-http:// request: it
serves the CAPTCHA_INSECURE_TEMPLATE_PATH page instead (the stock one asks the
visitor to retry over https://), or falls back to the ban remediation when that
page is not configured. Solving the captcha over HTTPS releases the address for
plain HTTP too.
Two things still count as secure.
set $crowdsec_assume_secure 1; in a server block. This is how a deployment
that terminates TLS upstream says so, and it is the only way — X-Forwarded-Proto
is deliberately not consulted. nginx records that a trusted proxy is in the request
path; it does not record who wrote any particular header, so a proxy that sets
X-Forwarded-For but not X-Forwarded-Proto (two separate proxy_set_header lines,
so a common half-configuration) passes the visitor's own value straight through, and
nothing available inside nginx can tell that from the proxy's own assertion. An
operator assertion is trustworthy where a forwarded header is not. Without
this, every captcha decision on such a vhost becomes a denial telling an HTTPS
visitor to retry over HTTPS — and nothing in the log distinguishes that from a
genuinely plain-HTTP request, because from the bouncer's side the two are
identical. If your captcha decisions turn into 403s the moment you enable a
provider, this is the first thing to check.
A loopback origin. http://localhost, *.localhost, http://127.0.0.1 and
http://[::1] are themselves secure contexts, so local development over plain
HTTP is served the captcha as normal — provided the request also arrives from a
loopback address, since the Host header alone is the client's to choose. If nginx
runs in a container and the browser is on the host, the request arrives from the
bridge gateway instead: use $crowdsec_assume_secure in that vhost.
It also needs two things the other providers do not, both outside crowdsec.conf:
-
Two extra rocks, which is where the key derivation comes from:
luarocks install lua-resty-string 0.09-0 luarocks install lua-resty-openssl 1.8.0-1Those are the versions CI exercises, and the only ones this code is tested against. Pin them.
lua-resty-opensslis an FFI binding whose API tracks the OpenSSL it was built for, andaltcha.luareaches intoresty.openssl.kdfandresty.openssl.digestdirectly — a release that reshapes either turns every captcha decision intoFALLBACK_REMEDIATIONwith one line in the startup log, and one that removes a function stops nginx starting outright. This repository is consumed by commit pin, so its Lua is frozen while unpinned rocks are not.Both are pure-Lua FFI bindings and need no build dependencies. They are loaded only when
CAPTCHA_PROVIDER=altcha; if they are missing, altcha refuses to configure and the bouncer degrades toFALLBACK_REMEDIATION, leaving every other provider working. -
A dedicated shared dict in the nginx
httpblock:lua_shared_dict crowdsec_altcha 10m;
Without it, altcha refuses to configure and the bouncer degrades to
FALLBACK_REMEDIATION— refused rather than shared, because challenges are attacker-paced writes, and letting them fall back intocrowdsec_cachewould let a rotating source evict the decision cache the bouncer exists to enforce.Size it generously, because running out is worse than slow. Challenges are keyed by client IP and evicted LRU, and an IP whose challenge is evicted has to be issued a fresh one — which counts against a per-IP mint ceiling that exists to stop anyone turning page reloads into key derivations. Enough eviction inside one 20 minute window and that ceiling is reached, at which point the visitor stops being offered a captcha and starts being served the ban page instead. Behind NAT or CGNAT, everyone on that address shares the outcome.
Budget about 800 bytes per outstanding challenge, so the 10m above holds roughly 13,000 concurrent ones. That is more than the payload suggests: one challenge is two dict entries — the JSON with its redeeming key prepended, and the mint counter — and nginx's slab allocator rounds each up to a power of two and adds a node header, so a ~300-byte challenge entry occupies a 512-byte slab before the counter is even stored.
Tuning (ALTCHA_COST, ALTCHA_COMPLEXITY, ALTCHA_ALGORITHM,
ALTCHA_MINTS_PER_SECOND) is documented inline
in config_example.conf. The practical ceiling on the first
two is the widget's own 90 second solve timeout: the bouncer holds the "owes us a
captcha" state for five minutes from serving the page, and solving starts as the
page paints, so the widget gives up well before the bouncer does.
Caution
OVERRIDE_REMEDIATION=captcha combined with this provider converts ban decisions
into a proof of work, which is a CPU cost rather than a human-presence test and is
straightforwardly scriptable — t/25 does it in about forty lines of Perl with no
browser. One solve releases the address for the whole of CAPTCHA_EXPIRATION. See
the notes on that setting in config_example.conf.
It also leaves /favicon.ico reachable for banned addresses: captcha decisions
have always exempted that path (the captcha page's own favicon request would
otherwise overwrite the address the visitor is released to), and the override
turns ban decisions into captcha decisions. Keep /favicon.ico a static file.
ALTCHA_WIDGET_FILE=/var/lib/crowdsec/lua/assets/altcha-3.2.1.js
ALTCHA_WIDGET_PATH=/.crowdsec/altcha-3.2.1.js
Set both and the bouncer reads the bundle once at startup and serves it from that
path itself, with a year of immutable caching; the captcha page's script tag then
points there instead of at the CDN, without an integrity attribute — a page served
from your own origin has no third party left to distrust, and a stale hash would fail
silently. Set one without the other, or name a file that cannot be read, and the pair
is ignored with a line in the log and the CDN copy is used instead.
Verify the bundle where you fetch it (a checksum in your image build, say) rather than in the browser, and put the version in the path so a new bundle arrives under a new URL.
The bundle goes out as application/javascript, which is not in nginx's default
gzip_types — that default is text/html alone. Add it there if you want the
response compressed; the bouncer sets Content-Length and leaves the encoding to
nginx rather than holding a second, compressed copy.
The bundle is served from the access phase, before any remediation runs. That is
what makes it work on every vhost with no location block, and it is also
required rather than merely convenient: the script is fetched by an address that is
being captcha'd, so any other handling would answer it with the captcha page —
arriving as HTML where the browser expects a module, and rewriting the URI the
visitor is released to, so solving would drop them on the .js file instead of the
page they asked for. No EXCLUDE_LOCATION entry is needed.
templates/captcha.html now renders the widget through {{captcha_frontend_js_tag}}
and {{captcha_widget}}, because providers disagree on how the widget is loaded and
declared. The previous {{captcha_frontend_js}}, {{captcha_frontend_key}} and
{{captcha_site_key}} are still populated, so templates written against the old
layout keep working with the non-altcha providers — but altcha needs
{{captcha_widget}} and will refuse to start without it. Deploy lib/ and
templates/ together.
Any server block can point the bouncer at its own pages, following the same pattern
as the $crowdsec_enable_bouncer toggles:
set $crowdsec_ban_template /error_pages/user/example.com/ban.html;
set $crowdsec_captcha_template /error_pages/user/example.com/captcha.html;
set $crowdsec_captcha_insecure_template /error_pages/user/example.com/captcha_insecure.html;The variables are optional. A vhost that never sets one, sets it empty, or names a file that does not exist falls back to the global template from the bouncer config — so a generated config can safely name the path for every vhost and let only the hosts that actually have a page use it. Files are read per serve, so edits take effect immediately, without a reload.
Notes:
- The captcha override is compiled per serve with the same placeholders as
templates/captcha.html, and for altcha it must carry exactly one{{captcha_widget}}— a page without it degrades to the stock captcha page and logs why, rather than serving a widget nobody can solve. - The ban and insecure pages are served raw; no placeholders.
- A global
REDIRECT_LOCATIONstill wins over a per-vhost ban page — it is instance-wide policy. - Override paths containing
..are refused.setinterpolates runtime variables, so a config that splices$hostinto the path hands the client a say in it; the refusal keeps that from becoming a traversal. Prefer paths interpolated at config-generation time.