Files
shadowdao 17bac2ac2f 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).
2026-09-17 16:07:18 -07:00

6.6 KiB

mastodon-altcha-captcha

Self-hosted ALTCHA 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:
    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:

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