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