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).
231 lines
11 KiB
Ruby
231 lines
11 KiB
Ruby
# 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
|