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,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
|
||||
Reference in New Issue
Block a user