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).
This commit is contained in:
2026-09-17 16:07:18 -07:00
commit 17bac2ac2f
4 changed files with 483 additions and 0 deletions
+135
View File
@@ -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 <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.
+1
View File
File diff suppressed because one or more lines are too long
+117
View File
@@ -0,0 +1,117 @@
-# LOCAL OVERRIDE for anti-social.online, added 2026-09-17.
-# Copy of app/views/auth/registrations/new.html.haml from Mastodon v4.7.2,
-# with a self-hosted ALTCHA widget added before the submit button. See
-# config/initializers/zz_altcha.rb for why this lives here instead of
-# editing the tracked view directly, and for the caveat that upstream
-# changes to the real template won't be reflected here automatically --
-# diff this against app/views/auth/registrations/new.html.haml after any
-# future Mastodon upgrade.
- content_for :page_title do
= t('auth.register')
- content_for :header_tags do
= render partial: 'shared/og', locals: { description: description_for_sign_up(@invite) }
-# Using the /dist/main (default) build, NOT /dist/external: confirmed
-# 2026-09-17 that /dist/external never calls algorithms.set() anywhere in
-# its source (grep on the vendored file: zero matches) -- it expects the
-# consumer to manually import and register a worker per algorithm, same
-# as the Argon2/Scrypt pattern in the README, which we never did. Real
-# users hit "Unsupported algorithm SHA-256" because of this (verify_solution
-# was never the bug -- the widget never got far enough to solve anything).
-# The default bundle self-registers SHA-256/PBKDF2 on load, at the cost of
-# one inline <style> tag that needs a CSP hash allowance (see
-# Auth::RegistrationsController.content_security_policy below).
-#
-# CACHE-BUSTING (also added 2026-09-17, same incident): public/local/ is
-# served with a 24h Cache-Control and every redeploy overwrites the SAME
-# filename. A real user's browser -- and, it turns out, my own test
-# browser's persistent cache during earlier iteration -- kept serving an
-# OLD cached copy indefinitely after every fix, with no way to tell from
-# the outside that the fix hadn't actually reached them. Content-hashing
-# the URL (recomputed on every render, not hand-maintained) means any
-# future change to the vendored file is a new URL, so this can't recur.
- 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 }
= simple_form_for(resource, as: resource_name, url: registration_path(resource_name), html: { novalidate: false }) do |f|
= render 'auth/shared/progress', stage: 'details'
%h1.title= t('auth.sign_up.title', domain: site_hostname)
%p.lead= t('auth.sign_up.preamble')
= render 'shared/error_messages', object: resource
- if @invite.present? && @invite.autofollow?
.fields-group.invited-by
%p.hint= t('invites.invited_by')
= render 'application/card', account: @invite.user.account
.fields-group
= f.simple_fields_for :account do |ff|
= ff.input :username,
append: "@#{site_hostname}",
input_html: { autocomplete: 'off', pattern: '[a-zA-Z0-9_]+', maxlength: Account::USERNAME_LENGTH_LIMIT },
required: true,
wrapper: :with_label
= f.input :email,
hint: false,
input_html: { autocomplete: 'username' },
required: true,
wrapper: :with_label
= f.input :password,
hint: false,
input_html: { autocomplete: 'new-password', minlength: User.password_length.first, maxlength: User.password_length.last },
required: true,
wrapper: :with_label
= f.input :password_confirmation,
hint: false,
input_html: { 'aria-label': t('simple_form.labels.defaults.confirm_password'), autocomplete: 'new-password', maxlength: User.password_length.last },
placeholder: t('simple_form.labels.defaults.confirm_password'),
required: true
= f.input :confirm_password,
as: :string,
hint: false,
input_html: { 'aria-label': t('simple_form.labels.defaults.honeypot', label: t('simple_form.labels.defaults.password')), autocomplete: 'off' },
placeholder: t('simple_form.labels.defaults.honeypot', label: t('simple_form.labels.defaults.password')),
required: false
= f.input :website,
as: :url,
input_html: { 'aria-label': t('simple_form.labels.defaults.honeypot', label: 'Website'), autocomplete: 'off' },
label: t('simple_form.labels.defaults.honeypot', label: 'Website'),
required: false,
wrapper: :with_label
- if Setting.min_age.present?
.fields-group
= f.input :date_of_birth,
as: :date_of_birth,
hint: t('simple_form.hints.user.date_of_birth', count: Setting.min_age.to_i, domain: site_hostname),
required: true,
wrapper: :with_block_label
- if approved_registrations? && !@invite&.bypass_approval?
.fields-group
= f.simple_fields_for :invite_request, resource.invite_request || resource.build_invite_request do |invite_request_fields|
= invite_request_fields.input :text,
as: :text,
hint: t('auth.sign_up.manual_review', domain: site_hostname),
input_html: { maxlength: UserInviteRequest::TEXT_SIZE_LIMIT },
required: Setting.require_invite_text,
wrapper: :with_block_label
= hidden_field_tag :accept, params[:accept]
= f.input :invite_code, as: :hidden
.fields-group
= f.input :agreement,
as: :boolean,
label: terms_agreement_label,
required: false,
wrapper: :with_label
.fields-group
%altcha-widget{ name: 'altcha', challenge: local_altcha_challenge_path, auto: 'onsubmit' }
.actions
= f.button :button, @invite&.bypass_approval? ? t('auth.register') : sign_up_message, type: :submit
+230
View File
@@ -0,0 +1,230 @@
# frozen_string_literal: true
#
# LOCAL OVERRIDE for anti-social.online — self-hosted ALTCHA proof-of-work
# captcha on the sign-up form, added 2026-09-17 in response to a bot farm
# flooding registrations (see reject_spam_signups.rb in this same directory
# for the cleanup side of that incident).
#
# WHY A HOME-GROWN IMPLEMENTATION:
# Mastodon ships hCaptcha support out of the box (Gemfile, Auth::CaptchaConcern),
# but that's a third-party vendor relationship, which we specifically don't
# want here. ALTCHA (https://altcha.org, MIT licensed) is a fully self-hosted,
# stateless proof-of-work captcha -- no vendor account, no external network
# call at verify time. There's no Ruby gem needed: the protocol is just
# SHA-256 + HMAC-SHA256 over a few string fields, ported by hand below from
# the "classic" (v1) algorithm in github.com/altcha-org/altcha-lib
# (src/v1/index.ts, fetched 2026-09-17 to get the exact wire format right).
#
# WHAT THIS BUYS US:
# Stops the specific bot we saw (crafts raw POST /auth requests, no JS
# execution) dead -- it has no idea this extra step exists at all.
# HONEST LIMITATION: the PoW algorithm is published and open. A determined
# attacker who specifically targets this instance could reimplement the
# solver in a script and grind through it just as fast as a browser. This
# raises the cost of automation; it isn't cryptographically unbreakable.
# Bump Local::Altcha::DEFAULT_MAX_NUMBER upward if that ever happens.
#
# WHY IT SURVIVES UPGRADES:
# Everything here is defined in this UNTRACKED initializer (git checkout on
# upgrade never touches it) rather than editing Auth::RegistrationsController
# or its view directly. Canonical copy: /home/mastodon/local-overrides/ --
# run restore-overrides.sh to reinstate after a `git clean -fd`.
#
# MOVING PARTS (all untracked, all reinstalled by restore-overrides.sh):
# - this file -> 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
# - ALTCHA_HMAC_KEY in .env.production (generated once, never rotated
# automatically -- rotating it invalidates any challenge issued in the
# last 5 minutes, harmless)
# - nginx: location = /local/altcha-challenge (proxy_cache OFF -- critical,
# see below) and location ^~ /local/ (static JS) in sites-available/mastodon
#
# NGINX CACHING GOTCHA (do not skip this if re-deploying by hand):
# The vhost's generic @proxy location has `proxy_cache_valid 200 7d`. If the
# challenge endpoint isn't given its own `proxy_cache off` location, nginx
# would cache the FIRST visitor's random challenge and serve that same
# challenge to everyone for a week -- letting one solved token be replayed
# instance-wide, and locking out everyone else once our own replay-guard
# below rejects the reused challenge. Must stay an exact-match, cache-off
# location block.
require 'base64'
module Local
module Altcha
DEFAULT_ALGORITHM = 'SHA-256'
DEFAULT_MAX_NUMBER = 100_000
SALT_BYTES = 12
EXPIRES_IN = 5.minutes
module_function
def hmac_key
ENV.fetch('ALTCHA_HMAC_KEY')
end
def digest_class(algorithm)
case algorithm
when 'SHA-1' then OpenSSL::Digest::SHA1
when 'SHA-256' then OpenSSL::Digest::SHA256
when 'SHA-512' then OpenSSL::Digest::SHA512
else raise ArgumentError, "unsupported altcha algorithm: #{algorithm}"
end
end
def hash_hex(algorithm, data)
digest_class(algorithm).hexdigest(data)
end
def hmac_hex(algorithm, data, key)
OpenSSL::HMAC.hexdigest(digest_class(algorithm).new, key, data)
end
# Mirrors altcha-lib's v1 createChallenge(): salt gets an embedded
# "?expires=<unix_ts>&" suffix, challenge = sha256(salt + number),
# signature = hmac_sha256(secret, challenge). The client widget brute
# forces `number` until it finds one that reproduces `challenge`.
def create_challenge(algorithm: DEFAULT_ALGORITHM, maxnumber: DEFAULT_MAX_NUMBER)
salt = SecureRandom.hex(SALT_BYTES)
expires_at = Time.now.utc.to_i + EXPIRES_IN.to_i
salt = "#{salt}?expires=#{expires_at}&"
number = SecureRandom.random_number(maxnumber) + 1
challenge = hash_hex(algorithm, "#{salt}#{number}")
signature = hmac_hex(algorithm, challenge, hmac_key)
{ algorithm: algorithm, challenge: challenge, maxnumber: maxnumber, salt: salt, signature: signature }
end
# Mirrors altcha-lib's v1 verifySolution(): recompute challenge+signature
# from the payload's own salt/number using OUR secret, and compare. A
# forged payload can't produce a matching signature without the secret;
# a payload that skipped the proof-of-work can't produce a matching
# challenge hash. Plus a one-time-use guard via Rails.cache so a solved
# payload can't be replayed within its expiry window.
def verify_solution(payload_b64)
payload = JSON.parse(Base64.decode64(payload_b64.to_s), symbolize_names: true)
algorithm = payload[:algorithm].to_s
salt = payload[:salt].to_s
number = payload[:number]
challenge = payload[:challenge].to_s
signature = payload[:signature].to_s
return false if [algorithm, salt, challenge, signature].any?(&:blank?) || number.nil?
query = salt.split('?', 2)[1].to_s.delete_suffix('&')
expires = Rack::Utils.parse_nested_query(query)['expires']
return false if expires.blank?
return false if Time.at(expires.to_i) < Time.now.utc
expected_challenge = hash_hex(algorithm, "#{salt}#{number}")
return false unless ActiveSupport::SecurityUtils.secure_compare(expected_challenge, challenge)
expected_signature = hmac_hex(algorithm, expected_challenge, hmac_key)
return false unless ActiveSupport::SecurityUtils.secure_compare(expected_signature, signature)
cache_key = "altcha:used:#{challenge}"
return false if Rails.cache.exist?(cache_key)
Rails.cache.write(cache_key, true, expires_in: EXPIRES_IN + 1.minute)
true
rescue StandardError => e
Rails.logger.info("[altcha] verify_solution rejected: #{e.class}: #{e.message}")
false
end
end
end
# NOTE: `.append` inserts AFTER config/routes.rb's own routes -- including
# Mastodon's catch-all 404 route at the very end, which swallows our route
# before it's ever reached (confirmed: 404'd with Mastodon's own error page
# on first deploy, 2026-09-17). `.prepend` inserts before the app's routes,
# which is what a genuinely new route needs.
Rails.application.routes.prepend do
get '/local/altcha-challenge', to: 'local/altcha#challenge', as: :local_altcha_challenge
end
Rails.application.config.to_prepare do
# Standalone controller (not under Mastodon's ApplicationController -- this
# is a public, unauthenticated, read-only JSON endpoint with nothing to
# protect beyond what nginx's rate limit already covers). Defined here
# rather than at initializer top-level: ActionController::Base's
# `inherited` hook eagerly runs `helper :all`, which constantizes every
# app/helpers/*_helper.rb file -- those aren't loaded yet this early in
# boot, so defining the class before eager load finishes raises
# `NameError: uninitialized constant AccountsHelper` (hit this on first
# deploy, 2026-09-17). to_prepare runs after eager loading completes.
unless defined?(Local::AltchaController)
altcha_controller = Class.new(ActionController::Base) do
def challenge
response.headers['Cache-Control'] = 'no-store'
render json: Local::Altcha.create_challenge
end
end
Local.const_set(:AltchaController, altcha_controller)
end
# Serve our own copy of the sign-up view (prepended path is checked first,
# so this shadows app/views/auth/registrations/new.html.haml without ever
# touching that tracked file). Everything else on the site is untouched --
# this path only has the one file we put there.
views_local = Rails.root.join('app', 'views_local').to_s
ActionController::Base.prepend_view_path(views_local) unless ActionController::Base.view_paths.map(&:to_s).include?(views_local)
next unless defined?(Auth::RegistrationsController)
next if Auth::RegistrationsController.private_method_defined?(:check_altcha!)
Auth::RegistrationsController.class_eval do
before_action :check_altcha!, only: [:create]
private
def check_altcha!
return if Local::Altcha.verify_solution(params[:altcha])
build_resource(sign_up_params)
flash.now[:alert] = 'Verification failed -- please try again.'
render :new, status: :unprocessable_entity
end
end
# BLOCK API-BASED ACCOUNT CREATION, added 2026-09-17 same day as the web
# captcha above. Turns out the bot wasn't using the web sign-up form at
# all -- server logs showed it minting a fresh throwaway OAuth app via
# POST /api/v1/apps immediately before every POST /api/v1/accounts, which
# is Mastodon's REST API registration endpoint (used by third-party apps
# like Tusky/Ivory to let a user "Create Account" in-app). That's a
# completely different controller (Api::V1::AccountsController), so the
# web-form altcha guard above never saw this traffic at all -- confirmed
# bot accounts kept appearing minutes after the web captcha went live.
#
# Decision (Josh, 2026-09-17): disable new-account creation via the API
# outright rather than try to 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 there is functionally
# equivalent to a flat 403) -- disabling is simpler. Existing users' apps
# are unaffected; this only blocks the "create a new account" in-app flow,
# and anyone who wants an account uses the website instead (which has the
# captcha).
if defined?(Api::V1::AccountsController) && !Api::V1::AccountsController.private_method_defined?(:block_api_account_creation)
Api::V1::AccountsController.class_eval do
before_action :block_api_account_creation, only: [:create]
private
def block_api_account_creation
forbidden
end
end
end
# The altcha widget (even the CSP-friendly /dist/external build) injects
# one small, fixed inline <style> tag for the custom element's default
# display. Confirmed via browser console during testing 2026-09-17:
# 'sha256-ZgqGuQlekW98cv0XQjYUGCLTvc3q5MkU+2SkqlFGoTM=' is what Chrome
# reported as the blocked content's hash. Same idiom Mastodon's own
# CSP initializer already uses for PgHero -- extend the directive rather
# than replace it, so the base style-src (self + nonce) still applies.
Auth::RegistrationsController.content_security_policy do |p|
existing = p.style_src.presence || [:self]
p.style_src(*existing, "'sha256-ZgqGuQlekW98cv0XQjYUGCLTvc3q5MkU+2SkqlFGoTM='")
end
end