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:
@@ -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.
|
||||||
Vendored
+1
File diff suppressed because one or more lines are too long
@@ -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
@@ -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
|
||||||
Reference in New Issue
Block a user