Built for anti-social.online 2026-09-17 after a bot-farm registration wave. Blocks POST /api/v1/accounts entirely (that's what the bot actually used) and adds a self-hosted proof-of-work captcha to the web sign-up form as a second layer. See README for deploy steps and the two gotchas that caused a follow-up outage the same day (wrong widget build variant, and missing cache-busting on the vendored JS).
136 lines
6.6 KiB
Markdown
136 lines
6.6 KiB
Markdown
# mastodon-altcha-captcha
|
|
|
|
Self-hosted [ALTCHA](https://altcha.org) proof-of-work captcha for a Mastodon
|
|
sign-up form, built for anti-social.online (2026-09-17) after a bot farm
|
|
started hammering registrations. No vendor account, no external network call
|
|
at verify time — the whole thing is ~150 lines of Ruby (stdlib SHA-256 +
|
|
HMAC-SHA256) plus the vendored widget JS.
|
|
|
|
Deployed as an **untracked local-overrides bundle** — nothing here edits a
|
|
tracked Mastodon file directly, so `git checkout <tag>` on an upgrade never
|
|
reverts it. Same pattern as other customizations on this instance (see the
|
|
character-limit override).
|
|
|
|
## What's in here
|
|
|
|
| File | Installs to |
|
|
|---|---|
|
|
| `zz_altcha.rb` | `config/initializers/zz_altcha.rb` |
|
|
| `views/auth/registrations/new.html.haml` | `app/views_local/auth/registrations/new.html.haml` |
|
|
| `altcha.min.js` | `public/local/altcha.min.js` |
|
|
|
|
`zz_altcha.rb` is the whole thing: the ALTCHA protocol implementation
|
|
(`Local::Altcha`), the challenge-issuing controller, a `prepend_view_path`
|
|
so the view override above actually gets used, a `before_action` on
|
|
`Auth::RegistrationsController#create` that gates account creation on a
|
|
solved challenge, and — separately — a `before_action` on
|
|
`Api::V1::AccountsController#create` that blocks account creation via the
|
|
REST API entirely (see "Why the API is blocked" below).
|
|
|
|
`altcha.min.js` is vendored from **npm `altcha@3.2.2`, `dist/main/altcha.min.js`
|
|
specifically** (MIT license, github.com/altcha-org/altcha). Not `dist/external`
|
|
— see gotchas.
|
|
|
|
## Manual deploy steps (not covered by these files)
|
|
|
|
1. Copy the three files to the paths above and restart `mastodon-web`.
|
|
2. Add an HMAC secret to `.env.production`:
|
|
```
|
|
ALTCHA_HMAC_KEY=<openssl rand -hex 32>
|
|
```
|
|
3. nginx (`sites-available/mastodon`), inside the `server { listen 443 ... }` block:
|
|
```nginx
|
|
location = /local/altcha-challenge {
|
|
proxy_set_header Host $host;
|
|
proxy_set_header X-Real-IP $remote_addr;
|
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
proxy_set_header X-Forwarded-Proto $scheme;
|
|
proxy_pass http://backend;
|
|
proxy_cache off; # CRITICAL, see gotchas
|
|
add_header Cache-Control "no-store";
|
|
}
|
|
|
|
location ^~ /local/ {
|
|
add_header Cache-Control "public, max-age=86400";
|
|
try_files $uri =404;
|
|
}
|
|
```
|
|
Adjust `proxy_pass` target / upstream name to match your vhost.
|
|
|
|
## Why the API registration endpoint is blocked too
|
|
|
|
Deploying the web-form captcha alone did nothing — logs showed the bot was
|
|
never hitting the web form. It was using Mastodon's REST API registration
|
|
flow instead (`POST /api/v1/apps` to mint a throwaway OAuth app, then
|
|
`POST /api/v1/accounts`), a completely different controller
|
|
(`Api::V1::AccountsController`) that the web form's captcha can't reach.
|
|
|
|
Decision made here: disable API-based account creation outright rather than
|
|
extend ALTCHA to the API too. Both options block the same unmodified
|
|
third-party apps in practice (none of them know to send an `altcha` token,
|
|
so requiring one is functionally a flat 403 anyway) — disabling is simpler.
|
|
Existing users' apps (login, posting, everything except *creating a brand
|
|
new account* in-app) are unaffected.
|
|
|
|
**If your instance actually needs API-based sign-up** (e.g. you rely on
|
|
users creating accounts through a mobile client), delete that
|
|
`Api::V1::AccountsController` block in `zz_altcha.rb` and think about a
|
|
different mitigation for that endpoint specifically.
|
|
|
|
## Gotchas (both cost a live outage to find)
|
|
|
|
1. **Use `dist/main`, not `dist/external`.** The `/dist/external` build (the
|
|
one the altcha docs recommend for strict-CSP setups) never calls
|
|
`algorithms.set(...)` anywhere in its source — it expects you to
|
|
separately import and register a worker per algorithm, same as the
|
|
documented Argon2/Scrypt pattern. Skip that step (easy to, since nothing
|
|
errors loudly) and every verification attempt fails with
|
|
`Unsupported algorithm SHA-256`. `dist/main` self-registers SHA-256 (and
|
|
friends) unconditionally at module load — use that instead, and allow its
|
|
one small inline `<style>` tag via a CSP hash (see the
|
|
`content_security_policy` block at the bottom of `zz_altcha.rb` for the
|
|
exact pattern — same technique Mastodon's own CSP initializer uses for
|
|
PgHero).
|
|
|
|
2. **Cache-bust the vendored JS.** `public/local/` gets a long
|
|
`Cache-Control` (fine — it's an immutable-ish vendored file), but if you
|
|
ever update `altcha.min.js` in place at the same URL, browsers that
|
|
already cached the old copy will keep serving it for the full max-age,
|
|
with no visible error pointing at caching as the cause. The view computes
|
|
a content hash at render time and appends it as a query param:
|
|
```ruby
|
|
- altcha_js_digest = Digest::MD5.file(Rails.public_path.join('local', 'altcha.min.js')).hexdigest[0, 10]
|
|
%script{ src: "/local/altcha.min.js?v=#{altcha_js_digest}", type: 'module', async: true, defer: true }
|
|
```
|
|
so any future swap of the file is automatically a new URL. Don't
|
|
reference the bare filename directly if you ever update the widget.
|
|
|
|
3. **`Rails.application.routes.append` gets shadowed by Mastodon's own
|
|
catch-all 404 route.** Use `.prepend` for any new route added from an
|
|
initializer, not `.append` (append is for routes meant to be tried
|
|
*last*, e.g. an engine's fallback).
|
|
|
|
4. Defining a bare `ActionController::Base` subclass at initializer
|
|
top-level crashes boot (`NameError: uninitialized constant
|
|
AccountsHelper`) — `ActionController::Base`'s `inherited` hook eagerly
|
|
runs `helper :all`, which isn't safe before eager loading finishes.
|
|
Define it inside `Rails.application.config.to_prepare do ... end` instead.
|
|
|
|
## Protocol notes
|
|
|
|
Ported by hand from `github.com/altcha-org/altcha-lib`'s **v1** (classic)
|
|
algorithm — `src/v1/index.ts` — not the newer v2 KDF-based protocol in the
|
|
same library. Salt embeds an expiry (`?expires=<unix_ts>&`), challenge is
|
|
`sha256(salt+number)`, signature is `hmac_sha256(secret, challenge)`, and the
|
|
server never sends the client the actual `number` — the widget brute-forces
|
|
it. Verification recomputes both from the payload's own fields and compares;
|
|
a one-time-use guard via `Rails.cache` prevents replay within the expiry
|
|
window (the core protocol alone doesn't).
|
|
|
|
Difficulty is `Local::Altcha::DEFAULT_MAX_NUMBER` (currently `100_000`,
|
|
sub-second solve time on modern hardware). This stops a script that doesn't
|
|
render pages at all (the observed bot's actual behavior) and raises the cost
|
|
of automation — it is not cryptographically unbreakable. The algorithm is
|
|
published; a sufficiently motivated attacker could reimplement the solver.
|
|
Raise the difficulty if that ever becomes the actual threat model.
|