commit 17bac2ac2f6c35e8d1499a06ef3d01b0f268babd Author: jknapp Date: Thu Sep 17 16:07:18 2026 -0700 Self-hosted ALTCHA captcha + API sign-up block for Mastodon 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). diff --git a/README.md b/README.md new file mode 100644 index 0000000..b0c3cd4 --- /dev/null +++ b/README.md @@ -0,0 +1,135 @@ +# 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 ` 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= + ``` +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 `