# 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=&" 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 # Not a real HTTP redirect -- these are JSON API clients (app # backends), not browsers, so a 3xx + Location header either gets # silently ignored or blindly followed into an HTML page where the # app expected JSON, which is worse than just failing. A descriptive # `error` message is what actually reaches a human: most Mastodon # apps surface that string on-screen when a request fails. def block_api_account_creation render json: { error: "Account creation via the API is disabled on this server. Please sign up at #{new_user_registration_url}." }, status: 403 end end end # The altcha widget (even the CSP-friendly /dist/external build) injects # one small, fixed inline